From 93eabfd496b2a9b2e687d0b924e1976797720685 Mon Sep 17 00:00:00 2001 From: Johannes Zottele Date: Mon, 11 Aug 2025 10:00:00 +0200 Subject: [PATCH 01/14] grpc-native: Initial client setup Signed-off-by: Johannes Zottele --- cinterop-c/BUILD.bazel | 9 +- cinterop-c/include/grpcpp_c.h | 15 ++ cinterop-c/src/grpcpp_c.cpp | 9 ++ .../nativeInterop/cinterop/libgrpcpp_c.def | 4 +- .../kotlinx/rpc/grpc/ManagedChannel.native.kt | 74 +++++++++- .../kotlin/kotlinx/rpc/grpc/Status.native.kt | 20 ++- .../rpc/grpc/StatusException.native.kt | 42 +++--- .../rpc/grpc/internal/ClientCall.native.kt | 21 ++- .../rpc/grpc/internal/CompletionQueue.kt | 115 +++++++++++++++ .../grpc/internal/GrpcCallOptions.native.kt | 7 +- .../rpc/grpc/internal/GrpcContext.native.kt | 8 +- .../grpc/internal/MethodDescriptor.native.kt | 88 ++++++++---- .../rpc/grpc/internal/ServerCall.native.kt | 25 +++- .../kotlin/kotlinx/rpc/grpc/internal/utils.kt | 20 +++ .../kotlinx/rpc/grpc/internal/CoreTest.kt | 131 ++++++++++++++++++ .../grpc/internal/UnexpectedCleanerTest.kt | 64 +++++++++ 16 files changed, 575 insertions(+), 77 deletions(-) create mode 100644 grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/CompletionQueue.kt create mode 100644 grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/utils.kt create mode 100644 grpc/grpc-core/src/nativeTest/kotlin/kotlinx/rpc/grpc/internal/CoreTest.kt create mode 100644 grpc/grpc-core/src/nativeTest/kotlin/kotlinx/rpc/grpc/internal/UnexpectedCleanerTest.kt diff --git a/cinterop-c/BUILD.bazel b/cinterop-c/BUILD.bazel index 4e3157af4..6d88c90ec 100644 --- a/cinterop-c/BUILD.bazel +++ b/cinterop-c/BUILD.bazel @@ -1,13 +1,5 @@ load("@rules_cc//cc:defs.bzl", "cc_library") -cc_binary( - name = "testdemo", - srcs = ["src/main.cpp"], - deps = [ - ":protowire", - ], -) - cc_static_library( name = "grpcpp_c_static", deps = [ @@ -24,6 +16,7 @@ cc_library( visibility = ["//visibility:public"], deps = [ # TODO: Reduce the dependencies and only use required once. KRPC-185 + "@com_github_grpc_grpc//:grpc", "@com_github_grpc_grpc//:channelz", "@com_github_grpc_grpc//:generic_stub", "@com_github_grpc_grpc//:grpc++", diff --git a/cinterop-c/include/grpcpp_c.h b/cinterop-c/include/grpcpp_c.h index c24e0f2d6..c18d038af 100644 --- a/cinterop-c/include/grpcpp_c.h +++ b/cinterop-c/include/grpcpp_c.h @@ -6,6 +6,7 @@ #define GRPCPP_C_H #include +#include #include #include @@ -38,6 +39,14 @@ typedef enum StatusCode { GRPC_C_STATUS_DO_NOT_USE = -1 } grpc_status_code_t; + +typedef struct { + grpc_completion_queue_functor functor; + void *user_data; +} grpc_cb_tag; + + + grpc_client_t *grpc_client_create_insecure(const char *target); void grpc_client_delete(const grpc_client_t *client); @@ -59,6 +68,12 @@ uint32_t pb_decode_greeter_sayhello_response(grpc_slice response); grpc_status_code_t grpc_byte_buffer_dump_to_single_slice(grpc_byte_buffer *byte_buffer, grpc_slice *slice); + +/////// CHANNEL /////// + +typedef struct grpc_channel grpc_channel_t; +typedef struct grpc_channel_credentials grpc_channel_credentials_t; + #ifdef __cplusplus } #endif diff --git a/cinterop-c/src/grpcpp_c.cpp b/cinterop-c/src/grpcpp_c.cpp index 2dd875e18..b2f06d49b 100644 --- a/cinterop-c/src/grpcpp_c.cpp +++ b/cinterop-c/src/grpcpp_c.cpp @@ -27,6 +27,10 @@ struct grpc_context { std::unique_ptr context; }; +// struct grpc_channel { +// std::shared_ptr channel; +// }; + extern "C" { grpc_client_t *grpc_client_create_insecure(const char *target) { @@ -196,6 +200,11 @@ extern "C" { return GRPC_C_STATUS_OK; } + + //// CHANNEL //// + + + } diff --git a/grpc/grpc-core/src/nativeInterop/cinterop/libgrpcpp_c.def b/grpc/grpc-core/src/nativeInterop/cinterop/libgrpcpp_c.def index 7ed20de3a..f18af391b 100644 --- a/grpc/grpc-core/src/nativeInterop/cinterop/libgrpcpp_c.def +++ b/grpc/grpc-core/src/nativeInterop/cinterop/libgrpcpp_c.def @@ -1,5 +1,5 @@ -headers = grpcpp_c.h -headerFilter= grpcpp_c.h grpc/slice.h grpc/byte_buffer.h +headers = grpcpp_c.h grpc/grpc.h grpc/credentials.h +headerFilter= grpcpp_c.h grpc/slice.h grpc/byte_buffer.h grpc/grpc.h grpc/impl/grpc_types.h grpc/credentials.h grpc/support/time.h noStringConversion = grpc_slice_from_copied_buffer my_grpc_slice_from_copied_buffer 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 7ad772253..e708bbb34 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 @@ -3,10 +3,21 @@ */ @file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") +@file:OptIn(ExperimentalForeignApi::class, ExperimentalNativeApi::class) package kotlinx.rpc.grpc +import cnames.structs.grpc_channel +import kotlinx.cinterop.CPointer +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.rpc.grpc.internal.ClientCall +import kotlinx.rpc.grpc.internal.GrpcCallOptions import kotlinx.rpc.grpc.internal.GrpcChannel +import kotlinx.rpc.grpc.internal.MethodDescriptor +import libgrpcpp_c.* +import kotlin.experimental.ExperimentalNativeApi +import kotlin.native.ref.createCleaner +import kotlin.time.Duration /** * Same as [ManagedChannel], but is platform-exposed. @@ -23,7 +34,13 @@ public actual abstract class ManagedChannelBuilder> } internal actual fun ManagedChannelBuilder<*>.buildChannel(): ManagedChannel { - error("Native target is not supported in gRPC") + return NativeManagedChannel( + target = "localhost:50051", + credentials = GrpcCredentials( + grpc_insecure_credentials_create() + ?: error("Failed to create credentials") + ) + ) } internal actual fun ManagedChannelBuilder(hostname: String, port: Int): ManagedChannelBuilder<*> { @@ -33,3 +50,58 @@ internal actual fun ManagedChannelBuilder(hostname: String, port: Int): ManagedC internal actual fun ManagedChannelBuilder(target: String): ManagedChannelBuilder<*> { error("Native target is not supported in gRPC") } + + +internal class NativeManagedChannel( + private val target: String, + // we must store them, otherwise the credentials are getting released + private val credentials: GrpcCredentials, +) : ManagedChannel, ManagedChannelPlatform() { + + internal val raw: CPointer = grpc_channel_create(target, credentials.raw, null) + ?: error("Failed to create channel") + private val rawCleaner = createCleaner(raw) { + grpc_channel_destroy(it) + } + + override val platformApi: ManagedChannelPlatform = this + + override val isShutdown: Boolean + get() = TODO("Not yet implemented") + override val isTerminated: Boolean + get() = TODO("Not yet implemented") + + override suspend fun awaitTermination(duration: Duration): Boolean { + TODO("Not yet implemented") + } + + override fun shutdown(): ManagedChannel { + TODO("Not yet implemented") + } + + override fun shutdownNow(): ManagedChannel { + TODO("Not yet implemented") + } + + + override fun newCall( + methodDescriptor: MethodDescriptor, + callOptions: GrpcCallOptions, + ): ClientCall { + TODO("Not yet implemented") + } + + override fun authority(): String { + TODO("Not yet implemented") + } + +} + + +internal class GrpcCredentials( + internal val raw: CPointer, +) { + val rawCleaner = createCleaner(raw) { + grpc_channel_credentials_release(it) + } +} 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 bd7103740..b097226c7 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 @@ -4,23 +4,21 @@ package kotlinx.rpc.grpc -public actual class Status { - public actual fun getDescription(): String? { - TODO("Not yet implemented") - } +public actual class Status internal constructor( + private val description: String?, + internal val statusCode: StatusCode, + private val cause: Throwable? +) { + public actual fun getDescription(): String? = description - public actual fun getCause(): Throwable? { - TODO("Not yet implemented") - } + public actual fun getCause(): Throwable? = cause } public actual val Status.code: StatusCode - get() = TODO("Not yet implemented") + get() = this.statusCode public actual fun Status( code: StatusCode, description: String?, cause: Throwable?, -): Status { - TODO("Not yet implemented") -} +): Status = Status(description, code, cause) 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 6c8739a96..ab6ba2002 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 @@ -5,37 +5,39 @@ package kotlinx.rpc.grpc public actual class StatusException : Exception { - public actual fun getStatus(): Status { - TODO("Not yet implemented") - } + private val status: Status + private val trailers: GrpcTrailers? - public actual fun getTrailers(): GrpcTrailers? { - TODO("Not yet implemented") + public actual constructor(status: Status) : super(status.getDescription()) { + this.status = status + this.trailers = null } - public actual constructor(status: Status) { - TODO("Not yet implemented") + public actual constructor(status: Status, trailers: GrpcTrailers?) : super(status.getDescription()) { + this.status = status + this.trailers = trailers } - public actual constructor(status: Status, trailers: GrpcTrailers?) { - TODO("Not yet implemented") - } + public actual fun getStatus(): Status = status + + public actual fun getTrailers(): GrpcTrailers? = trailers } public actual class StatusRuntimeException : RuntimeException { - public actual fun getStatus(): Status { - TODO("Not yet implemented") - } + private val status: Status + private val trailers: GrpcTrailers? - public actual fun getTrailers(): GrpcTrailers? { - TODO("Not yet implemented") + public actual constructor(status: Status) : super(status.getDescription()) { + this.status = status + this.trailers = null } - public actual constructor(status: Status) { - TODO("Not yet implemented") + public actual constructor(status: Status, trailers: GrpcTrailers?) : super(status.getDescription()) { + this.status = status + this.trailers = trailers } - public actual constructor(status: Status, trailers: GrpcTrailers?) { - TODO("Not yet implemented") - } + public actual fun getStatus(): Status = status + + public actual fun getTrailers(): GrpcTrailers? = 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 4f2b24850..aa8bb5c43 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 @@ -20,7 +20,8 @@ public actual abstract class ClientCall { public actual abstract fun halfClose() public actual abstract fun sendMessage(message: Request) public actual open fun isReady(): Boolean { - TODO("Not yet implemented") + // Default implementation returns true - subclasses can override if they need flow control + return true } @InternalRpcApi @@ -46,5 +47,21 @@ public actual fun clientCallListener( onClose: (status: Status, trailers: GrpcTrailers) -> Unit, onReady: () -> Unit, ): ClientCall.Listener { - TODO("Not yet implemented") + return object : ClientCall.Listener() { + override fun onHeaders(headers: GrpcTrailers) { + onHeaders(headers) + } + + override fun onMessage(message: Message) { + onMessage(message) + } + + override fun onClose(status: Status, trailers: GrpcTrailers) { + onClose(status, trailers) + } + + override fun onReady() { + onReady() + } + } } 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 new file mode 100644 index 000000000..ff47af9c9 --- /dev/null +++ b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/CompletionQueue.kt @@ -0,0 +1,115 @@ +/* + * Copyright 2023-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +@file:OptIn(ExperimentalForeignApi::class, ExperimentalNativeApi::class, ExperimentalStdlibApi::class) + +package kotlinx.rpc.grpc.internal + +import cnames.structs.grpc_call +import kotlinx.atomicfu.atomic +import kotlinx.cinterop.* +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.suspendCancellableCoroutine +import libgrpcpp_c.* +import platform.posix.memset +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.experimental.ExperimentalNativeApi +import kotlin.native.ref.createCleaner + +internal class CompletionQueue { + + // internal as it must be accessible from the SHUTDOWN_CB + internal val _shutdownDone = kotlinx.coroutines.CompletableDeferred() + + // used for spinning lock. false means not used (available) + private val batchStartGuard = atomic(false) + + private val thisStableRef = StableRef.create(this) + + private val shutdownFunctor = nativeHeap.alloc { + functor.functor_run = SHUTDOWN_CB + user_data = thisStableRef.asCPointer() + }.reinterpret() + + + val raw = grpc_completion_queue_create_for_callback(shutdownFunctor.ptr, null) + + private val thisStableRefCleaner = createCleaner(thisStableRef) { it.dispose() } + private val shutdownFunctorCleaner = createCleaner(shutdownFunctor) { nativeHeap.free(it) } + + suspend fun runBatch(call: CPointer, ops: CPointer, nOps: ULong) = coroutineScope { + suspendCancellableCoroutine { cont -> + val tag = newCbTag(cont, OPS_COMPLETE_CB) + + // synchronizes access to grpc_call_start_batch + while (!batchStartGuard.compareAndSet(expect = false, update = true)) { + // could not be set to true (currently hold by different thread) + } + + var err: UInt + try { + err = grpc_call_start_batch(call, ops, nOps, tag, null) + } finally { + batchStartGuard.value = false + } + + if (err != 0u) { + deleteCbTag(tag) + cont.resumeWithException(IllegalStateException("start_batch err=$err")) + return@suspendCancellableCoroutine + } + + + cont.invokeOnCancellation { + this // keep reference, otherwise the cleaners might get cleaned before batch finishes + TODO("Implement call operation cancellation") + } + } + } + + suspend fun shutdown() { + if (_shutdownDone.isCompleted) return + grpc_completion_queue_shutdown(raw) + _shutdownDone.await() + } +} + +@CName("kq_ops_complete_cb") +private fun opsCompleteCb(functor: CPointer?, ok: Int) { + val tag = functor!!.reinterpret() + val cont = tag.pointed.user_data!!.asStableRef>().get() + deleteCbTag(tag) + if (ok != 0) cont.resume(Unit) else cont.resumeWithException(IllegalStateException("batch failed")) +} + +@CName("kq_shutdown_cb") +private fun shutdownCb(functor: CPointer?, ok: Int) { + val tag = functor!!.reinterpret() + val cq = tag.pointed.user_data!!.asStableRef().get() + check(ok != 0) { "CQ shutdown failed" } + grpc_completion_queue_destroy(cq.raw) + cq._shutdownDone.complete(Unit) +} + +private val OPS_COMPLETE_CB = staticCFunction(::opsCompleteCb) +private val SHUTDOWN_CB = staticCFunction(::shutdownCb) + +private fun newCbTag( + userData: Any, + cb: CPointer?, Int) -> Unit>>, +): CPointer { + val tag = nativeHeap.alloc() + memset(tag.ptr, 0, sizeOf().convert()) + tag.functor.functor_run = cb + tag.user_data = StableRef.create(userData).asCPointer() + return tag.ptr +} + +@CName("grpc_cb_tag_destroy") +private fun deleteCbTag(tag: CPointer) { + tag.pointed.user_data!!.asStableRef().dispose() + nativeHeap.free(tag) +} \ No newline at end of file diff --git a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/GrpcCallOptions.native.kt b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/GrpcCallOptions.native.kt index 28b09cf76..f9eb10461 100644 --- a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/GrpcCallOptions.native.kt +++ b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/GrpcCallOptions.native.kt @@ -7,8 +7,9 @@ package kotlinx.rpc.grpc.internal import kotlinx.rpc.internal.utils.InternalRpcApi @InternalRpcApi -public actual class GrpcCallOptions +public actual class GrpcCallOptions { + // TODO: Do something with it +} @InternalRpcApi -public actual val GrpcDefaultCallOptions: GrpcCallOptions - get() = TODO("Not yet implemented") +public actual val GrpcDefaultCallOptions: GrpcCallOptions = GrpcCallOptions() 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 076073f08..dd60f03e1 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 @@ -8,16 +8,18 @@ import kotlin.coroutines.CoroutineContext internal actual class GrpcContext +private val currentGrpcContext = GrpcContext() + internal actual val CurrentGrpcContext: GrpcContext - get() = TODO("Not yet implemented") + get() = currentGrpcContext internal actual class GrpcContextElement : CoroutineContext.Element { actual override val key: CoroutineContext.Key - get() = TODO("Not yet implemented") + get() = Key actual companion object Key : CoroutineContext.Key { actual fun current(): GrpcContextElement { - TODO("Not yet implemented") + return GrpcContextElement() } } } diff --git a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/MethodDescriptor.native.kt b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/MethodDescriptor.native.kt index a4774b52c..847c7ed3a 100644 --- a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/MethodDescriptor.native.kt +++ b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/MethodDescriptor.native.kt @@ -4,6 +4,7 @@ package kotlinx.rpc.grpc.internal +import kotlinx.io.Source import kotlinx.rpc.grpc.codec.MessageCodec import kotlinx.rpc.internal.utils.InternalRpcApi import kotlinx.rpc.protobuf.input.stream.InputStream @@ -13,38 +14,40 @@ internal actual val MethodDescriptor<*, *>.type: MethodType get() = TODO("Not yet implemented") @InternalRpcApi -public actual class MethodDescriptor { - public actual fun getFullMethodName(): String { - TODO("Not yet implemented") - } +public actual class MethodDescriptor internal constructor( + private val fullMethodName: String, + private val requestMarshaller: Marshaller, + private val responseMarshaller: Marshaller, + internal val methodType: MethodType, + private val schemaDescriptor: Any?, + private val idempotent: Boolean, + private val safe: Boolean, + private val sampledToLocalTracing: Boolean, +) { + public actual fun getFullMethodName(): String = fullMethodName - public actual fun getServiceName(): String? { - TODO("Not yet implemented") + private val serviceName: String? by lazy { + val index = fullMethodName.lastIndexOf('/') + if (index == -1) { + null + } else { + fullMethodName.substring(0, index) + } } - public actual fun getRequestMarshaller(): Marshaller { - TODO("Not yet implemented") - } + public actual fun getServiceName(): String? = serviceName - public actual fun getResponseMarshaller(): Marshaller { - TODO("Not yet implemented") - } + public actual fun getRequestMarshaller(): Marshaller = requestMarshaller - public actual fun getSchemaDescriptor(): Any? { - TODO("Not yet implemented") - } + public actual fun getResponseMarshaller(): Marshaller = responseMarshaller - public actual fun isIdempotent(): Boolean { - TODO("Not yet implemented") - } + public actual fun getSchemaDescriptor(): Any? = schemaDescriptor - public actual fun isSafe(): Boolean { - TODO("Not yet implemented") - } + public actual fun isIdempotent(): Boolean = idempotent - public actual fun isSampledToLocalTracing(): Boolean { - TODO("Not yet implemented") - } + public actual fun isSafe(): Boolean = safe + + public actual fun isSampledToLocalTracing(): Boolean = sampledToLocalTracing public actual interface Marshaller { public actual fun stream(value: T): InputStream @@ -52,6 +55,10 @@ public actual class MethodDescriptor { } } +@InternalRpcApi +internal actual val MethodDescriptor<*, *>.type: MethodType + get() = this.methodType + @InternalRpcApi public actual fun methodDescriptor( fullMethodName: String, @@ -63,5 +70,36 @@ public actual fun methodDescriptor( safe: Boolean, sampledToLocalTracing: Boolean, ): MethodDescriptor { - TODO("Not yet implemented") + val requestMarshaller = object : MethodDescriptor.Marshaller { + override fun stream(value: Request): InputStream { + val source = requestCodec.encode(value) + return object : InputStream(source) {} + } + + override fun parse(stream: InputStream): Request { + return requestCodec.decode(stream.source) + } + } + + val responseMarshaller = object : MethodDescriptor.Marshaller { + override fun stream(value: Response): InputStream { + val source = responseCodec.encode(value) + return object : InputStream(source) {} + } + + override fun parse(stream: InputStream): Response { + return responseCodec.decode(stream.source) + } + } + + return MethodDescriptor( + fullMethodName = fullMethodName, + requestMarshaller = requestMarshaller, + responseMarshaller = responseMarshaller, + methodType = type, + schemaDescriptor = schemaDescriptor, + idempotent = idempotent, + safe = safe, + sampledToLocalTracing = sampledToLocalTracing, + ) } 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 29b06aede..bda5a17be 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 @@ -24,7 +24,8 @@ public actual abstract class ServerCall { public actual abstract fun close(status: Status, trailers: GrpcTrailers) public actual open fun isReady(): Boolean { - TODO("Not yet implemented") + // Default implementation returns true - subclasses can override if they need flow control + return true } public actual abstract fun isCancelled(): Boolean @@ -49,5 +50,25 @@ public actual fun serverCallListener( onComplete: (State) -> Unit, onReady: (State) -> Unit, ): ServerCall.Listener { - TODO("Not yet implemented") + return object : ServerCall.Listener() { + override fun onMessage(message: Message) { + onMessage(state, message) + } + + override fun onHalfClose() { + onHalfClose(state) + } + + override fun onCancel() { + onCancel(state) + } + + override fun onComplete() { + onComplete(state) + } + + override fun onReady() { + onReady(state) + } + } } 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 new file mode 100644 index 000000000..5eaf337e3 --- /dev/null +++ b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/utils.kt @@ -0,0 +1,20 @@ +/* + * Copyright 2023-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +@file:OptIn(ExperimentalForeignApi::class) + +package kotlinx.rpc.grpc.internal + +import kotlinx.cinterop.Arena +import kotlinx.cinterop.ExperimentalForeignApi + +internal suspend fun withArena(block: suspend (Arena) -> Unit) = + Arena().let { arena -> + try { + block(arena) + } finally { + arena.clear() + } + } + diff --git a/grpc/grpc-core/src/nativeTest/kotlin/kotlinx/rpc/grpc/internal/CoreTest.kt b/grpc/grpc-core/src/nativeTest/kotlin/kotlinx/rpc/grpc/internal/CoreTest.kt new file mode 100644 index 000000000..ab1d80ec1 --- /dev/null +++ b/grpc/grpc-core/src/nativeTest/kotlin/kotlinx/rpc/grpc/internal/CoreTest.kt @@ -0,0 +1,131 @@ +/* + * Copyright 2023-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ +@file:OptIn(ExperimentalForeignApi::class, ExperimentalStdlibApi::class, ExperimentalNativeApi::class) + +package kotlinx.rpc.grpc.internal + + +import kotlinx.cinterop.* +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import libgrpcpp_c.* +import kotlin.experimental.ExperimentalNativeApi +import kotlin.test.Test +import kotlin.test.fail + +class GrpcCoreTest { + val GRPC_PROPAGATE_DEFAULTS = 0x0000FFFFu + + suspend fun doCall(reqBytes: ByteArray) = withArena { arena -> + + val shutdownFunctor = arena.alloc() + shutdownFunctor.functor_run = staticCFunction { tag, success -> println("Shutting down") } + + val cq = CompletionQueue() +// val cq = grpc_completion_queue_create_for_callback(shutdownFunctor.ptr, null) + + val creds = grpc_insecure_credentials_create()!! + val channel = grpc_channel_create("localhost:50051", creds, null)!! + + val method = grpc_slice_from_copied_string("/helloworld.Greeter/SayHello") + + val call = grpc_channel_create_call( + channel, null, GRPC_PROPAGATE_DEFAULTS, cq.raw, + method, null, gpr_inf_future(GPR_CLOCK_REALTIME), null + ) + + println("Request bytes: ${reqBytes.toHexString()}") + + // make a grpc_slice from bytes (copied buffer) + val reqSlice = memScoped { + val ptr = allocArray(reqBytes.size) + for (i in reqBytes.indices) ptr[i] = reqBytes[i] + grpc_slice_from_copied_buffer(ptr, reqBytes.size.toULong()) + } + + val reqSlicePtr = reqSlice.getPointer(arena) + val req_buf = grpc_raw_byte_buffer_create(reqSlicePtr, 1u) + + // Use a single batch (no RECV_INITIAL_METADATA to keep it minimal) + val ops = arena.allocArray(6) + + // SEND_INITIAL_METADATA + ops[0].op = GRPC_OP_SEND_INITIAL_METADATA + ops[0].data.send_initial_metadata.count = 0u + + // SEND_MESSAGE + ops[1].op = GRPC_OP_SEND_MESSAGE + ops[1].data.send_message.send_message = req_buf + + // SEND_CLOSE_FROM_CLIENT + ops[2].op = GRPC_OP_SEND_CLOSE_FROM_CLIENT + + + val meta = arena.alloc() + grpc_metadata_array_init(meta.ptr) + ops[3].op = GRPC_OP_RECV_INITIAL_METADATA + ops[3].data.recv_initial_metadata.recv_initial_metadata = meta.ptr + + // RECV_MESSAGE -> grpc_byte_buffer** + val recvBufPtr = arena.alloc>() + ops[4].op = GRPC_OP_RECV_MESSAGE + ops[4].data.recv_message.recv_message = recvBufPtr.ptr + + // RECV_STATUS_ON_CLIENT + val statusCode = arena.alloc() + val statusDetails = arena.alloc() + val errorStr = arena.alloc>() + ops[5].op = GRPC_OP_RECV_STATUS_ON_CLIENT + ops[5].data.recv_status_on_client.status = statusCode.ptr + ops[5].data.recv_status_on_client.status_details = statusDetails.ptr + ops[5].data.recv_status_on_client.error_string = errorStr.ptr + // trailing metadata is optional; leave it null if not used + + + coroutineScope { + + launch { + println("Shutting down") + cq.shutdown() + println("Shutdown") + } + + launch { + println("Start continuation call") + cq.runBatch(call!!, ops, 6u) + println("Call continuation done") + } + }.join() + + + println("Status code: ${statusCode.value}") + println("Error string: ${errorStr.value?.toKString()}") + if (statusCode.value != GRPC_STATUS_OK) { + fail("Call failed with status code ${statusCode.value}") + } + + } + + @Test + fun grpcCoreDemo() = memScoped { + + grpc_init() + + // --- build protobuf HelloRequest { name = "world" } --- + // field 1 (tag=1, wire=2) => key = 0x0A + val name = "world".encodeToByteArray() + val reqBytes = ByteArray(2 + name.size).apply { + this[0] = 0x0A // field 1, length-delimited + this[1] = name.size.toByte() // length (assumes <128) + name.copyInto(this, 2) + } + + runBlocking { + doCall(reqBytes) + } + grpc_shutdown() + } + +} \ No newline at end of file diff --git a/grpc/grpc-core/src/nativeTest/kotlin/kotlinx/rpc/grpc/internal/UnexpectedCleanerTest.kt b/grpc/grpc-core/src/nativeTest/kotlin/kotlinx/rpc/grpc/internal/UnexpectedCleanerTest.kt new file mode 100644 index 000000000..77f8f0fe8 --- /dev/null +++ b/grpc/grpc-core/src/nativeTest/kotlin/kotlinx/rpc/grpc/internal/UnexpectedCleanerTest.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2023-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +@file:OptIn(ExperimentalForeignApi::class, ExperimentalNativeApi::class, ObsoleteWorkersApi::class) + +package kotlinx.rpc.grpc.internal + +import kotlinx.cinterop.* +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.suspendCancellableCoroutine +import platform.posix.sleep +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume +import kotlin.experimental.ExperimentalNativeApi +import kotlin.native.concurrent.ObsoleteWorkersApi +import kotlin.native.concurrent.TransferMode +import kotlin.native.concurrent.Worker +import kotlin.native.ref.createCleaner +import kotlin.test.Test +import kotlin.time.TimeSource + +fun startApiCall(callBack: CPointer Unit>>, ctx: COpaquePointer) { + val worker = Worker.start() + + worker.execute(TransferMode.SAFE, { Pair(callBack, ctx) }) { (cbPtr, ctx) -> + sleep(15u) + cbPtr.invoke(ctx) + } +} + +class MyCallbackApiWrapper { + + val myResource = Any() + val myResourceCleaner = createCleaner(myResource) { + // clean my resource + val timeSinceStart = TimeSource.Monotonic.markNow() + println("$timeSinceStart: My resource got cleaned") + } + + val callback = staticCFunction { ptr: COpaquePointer -> + val stableRef = ptr.asStableRef>() + stableRef.get().resume(Unit) + stableRef.dispose() + } + + suspend fun callMyApi() = suspendCancellableCoroutine { cont -> + val contRef = StableRef.create(cont) + startApiCall(callback, contRef.asCPointer()) + } +} + + +class UnexpectedCleanerTest { + @Test + fun test() { + runBlocking { + val apiWrapper = MyCallbackApiWrapper() + apiWrapper.callMyApi() + val timeSinceStart = TimeSource.Monotonic.markNow() + println("$timeSinceStart: My API call returned") + } + } +} \ No newline at end of file From bb2836e7332eeafb4e3589642fdb9c21b8de9af0 Mon Sep 17 00:00:00 2001 From: Johannes Zottele Date: Mon, 11 Aug 2025 19:01:13 +0200 Subject: [PATCH 02/14] grpc-native: Working CompletionQueue Signed-off-by: Johannes Zottele --- cinterop-c/include/grpcpp_c.h | 3 + cinterop-c/src/grpcpp_c.cpp | 5 +- .../nativeInterop/cinterop/libgrpcpp_c.def | 1 + .../rpc/grpc/internal/CompletionQueue.kt | 88 ++++++++++++++----- .../kotlinx/rpc/grpc/internal/CoreTest.kt | 19 ++-- 5 files changed, 82 insertions(+), 34 deletions(-) diff --git a/cinterop-c/include/grpcpp_c.h b/cinterop-c/include/grpcpp_c.h index c18d038af..6df6ffee4 100644 --- a/cinterop-c/include/grpcpp_c.h +++ b/cinterop-c/include/grpcpp_c.h @@ -6,6 +6,7 @@ #define GRPCPP_C_H #include +#include #include #include #include @@ -74,6 +75,8 @@ grpc_status_code_t grpc_byte_buffer_dump_to_single_slice(grpc_byte_buffer *byte_ typedef struct grpc_channel grpc_channel_t; typedef struct grpc_channel_credentials grpc_channel_credentials_t; +bool kgrpc_iomgr_run_in_background(); + #ifdef __cplusplus } #endif diff --git a/cinterop-c/src/grpcpp_c.cpp b/cinterop-c/src/grpcpp_c.cpp index b2f06d49b..5d5b9ec4b 100644 --- a/cinterop-c/src/grpcpp_c.cpp +++ b/cinterop-c/src/grpcpp_c.cpp @@ -10,6 +10,7 @@ #include #include #include +#include "src/core/lib/iomgr/iomgr.h" namespace pb = google::protobuf; @@ -203,7 +204,9 @@ extern "C" { //// CHANNEL //// - + bool kgrpc_iomgr_run_in_background() { + return grpc_iomgr_run_in_background(); + } } diff --git a/grpc/grpc-core/src/nativeInterop/cinterop/libgrpcpp_c.def b/grpc/grpc-core/src/nativeInterop/cinterop/libgrpcpp_c.def index f18af391b..b24317305 100644 --- a/grpc/grpc-core/src/nativeInterop/cinterop/libgrpcpp_c.def +++ b/grpc/grpc-core/src/nativeInterop/cinterop/libgrpcpp_c.def @@ -2,5 +2,6 @@ headers = grpcpp_c.h grpc/grpc.h grpc/credentials.h headerFilter= grpcpp_c.h grpc/slice.h grpc/byte_buffer.h grpc/grpc.h grpc/impl/grpc_types.h grpc/credentials.h grpc/support/time.h noStringConversion = grpc_slice_from_copied_buffer my_grpc_slice_from_copied_buffer +strictEnums = grpc_status_code, grpc_connectivity_state grpc_call_error staticLibraries = libgrpcpp_c_static.a 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 ff47af9c9..e5ffe9ac0 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 @@ -9,7 +9,6 @@ package kotlinx.rpc.grpc.internal import cnames.structs.grpc_call import kotlinx.atomicfu.atomic import kotlinx.cinterop.* -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.suspendCancellableCoroutine import libgrpcpp_c.* import platform.posix.memset @@ -19,9 +18,20 @@ import kotlin.coroutines.resumeWithException import kotlin.experimental.ExperimentalNativeApi import kotlin.native.ref.createCleaner +/** + * A coroutine wrapper around the grpc completion_queue, which manages message operations. + * It is based on the "new" callback API; therefore, there are no kotlin-side threads required to poll + * the queue. + */ internal class CompletionQueue { - // internal as it must be accessible from the SHUTDOWN_CB + private enum class State { OPEN, SHUTTING_DOWN, CLOSED } + + private val state = atomic(State.OPEN) + + // internal as it must be accessible from the SHUTDOWN_CB, + // but it shouldn't be used from outside this file. + @Suppress("PropertyName") internal val _shutdownDone = kotlinx.coroutines.CompletableDeferred() // used for spinning lock. false means not used (available) @@ -40,58 +50,94 @@ internal class CompletionQueue { private val thisStableRefCleaner = createCleaner(thisStableRef) { it.dispose() } private val shutdownFunctorCleaner = createCleaner(shutdownFunctor) { nativeHeap.free(it) } - suspend fun runBatch(call: CPointer, ops: CPointer, nOps: ULong) = coroutineScope { - suspendCancellableCoroutine { cont -> + init { + // Assert grpc_iomgr_run_in_background() to guarantee that the event manager provides + // IO threads and supports the callback API. + require(kgrpc_iomgr_run_in_background()) { "The gRPC iomgr is not running background threads, required for callback based APIs." } + } + + suspend fun runBatch(call: CPointer, ops: CPointer, nOps: ULong) = + suspendCancellableCoroutine { cont -> val tag = newCbTag(cont, OPS_COMPLETE_CB) + var err = grpc_call_error.GRPC_CALL_ERROR // synchronizes access to grpc_call_start_batch - while (!batchStartGuard.compareAndSet(expect = false, update = true)) { - // could not be set to true (currently hold by different thread) - } + withBatchStartLock { + if (state.value != State.OPEN) { + deleteCbTag(tag) + cont.resume(grpc_call_error.GRPC_CALL_ERROR_COMPLETION_QUEUE_SHUTDOWN) + return@suspendCancellableCoroutine + } - var err: UInt - try { err = grpc_call_start_batch(call, ops, nOps, tag, null) - } finally { - batchStartGuard.value = false } - if (err != 0u) { + if (err != grpc_call_error.GRPC_CALL_OK) { + // if the call was not successful, the callback will not be invoked. deleteCbTag(tag) - cont.resumeWithException(IllegalStateException("start_batch err=$err")) + cont.resume(err) return@suspendCancellableCoroutine } cont.invokeOnCancellation { - this // keep reference, otherwise the cleaners might get cleaned before batch finishes - TODO("Implement call operation cancellation") + @Suppress("UnusedExpression") + // keep reference, otherwise the cleaners might get invoked before the batch finishes + this + // cancel the call if one of its batches is canceled. + // grpc_call_cancel is thread-safe and can be called several times. + // the callback is invoked anyway, so the tag doesn't get deleted here. + grpc_call_cancel(call, null) } } - } suspend fun shutdown() { - if (_shutdownDone.isCompleted) return + if (!state.compareAndSet(State.OPEN, State.SHUTTING_DOWN)) { + // the first call to shutdown() makes transition and to SHUTTING_DOWN and + // initiates shut down. all other invocations await the shutdown. + _shutdownDone.await() + return + } + + // wait until all batch operations since the state transitions were started. + // this is required to prevent batches from starting after shutdown was initialized + withBatchStartLock { } + grpc_completion_queue_shutdown(raw) _shutdownDone.await() + state.value = State.CLOSED + } + + private inline fun withBatchStartLock(block: () -> Unit) { + try { + // spin until this thread occupies the guard + @Suppress("ControlFlowWithEmptyBody") + while (!batchStartGuard.compareAndSet(expect = false, update = true)) { + } + block() + } finally { + // set guard to "not occupied" + batchStartGuard.value = false + } } } +// kq stands for kompletion_queue lol @CName("kq_ops_complete_cb") private fun opsCompleteCb(functor: CPointer?, ok: Int) { val tag = functor!!.reinterpret() - val cont = tag.pointed.user_data!!.asStableRef>().get() + val cont = tag.pointed.user_data!!.asStableRef>().get() deleteCbTag(tag) - if (ok != 0) cont.resume(Unit) else cont.resumeWithException(IllegalStateException("batch failed")) + if (ok != 0) cont.resume(grpc_call_error.GRPC_CALL_OK) + else cont.resumeWithException(IllegalStateException("batch failed")) } @CName("kq_shutdown_cb") private fun shutdownCb(functor: CPointer?, ok: Int) { val tag = functor!!.reinterpret() val cq = tag.pointed.user_data!!.asStableRef().get() - check(ok != 0) { "CQ shutdown failed" } - grpc_completion_queue_destroy(cq.raw) cq._shutdownDone.complete(Unit) + grpc_completion_queue_destroy(cq.raw) } private val OPS_COMPLETE_CB = staticCFunction(::opsCompleteCb) diff --git a/grpc/grpc-core/src/nativeTest/kotlin/kotlinx/rpc/grpc/internal/CoreTest.kt b/grpc/grpc-core/src/nativeTest/kotlin/kotlinx/rpc/grpc/internal/CoreTest.kt index ab1d80ec1..73d9f0674 100644 --- a/grpc/grpc-core/src/nativeTest/kotlin/kotlinx/rpc/grpc/internal/CoreTest.kt +++ b/grpc/grpc-core/src/nativeTest/kotlin/kotlinx/rpc/grpc/internal/CoreTest.kt @@ -19,12 +19,7 @@ class GrpcCoreTest { val GRPC_PROPAGATE_DEFAULTS = 0x0000FFFFu suspend fun doCall(reqBytes: ByteArray) = withArena { arena -> - - val shutdownFunctor = arena.alloc() - shutdownFunctor.functor_run = staticCFunction { tag, success -> println("Shutting down") } - val cq = CompletionQueue() -// val cq = grpc_completion_queue_create_for_callback(shutdownFunctor.ptr, null) val creds = grpc_insecure_credentials_create()!! val channel = grpc_channel_create("localhost:50051", creds, null)!! @@ -86,18 +81,18 @@ class GrpcCoreTest { coroutineScope { - launch { - println("Shutting down") - cq.shutdown() - println("Shutdown") - } - launch { println("Start continuation call") cq.runBatch(call!!, ops, 6u) println("Call continuation done") } - }.join() + + launch { + println("Shutting down") + cq.shutdown() + println("Shutdown") + } + } println("Status code: ${statusCode.value}") From 29b42987611b501000c731d73f68f2819bf18779 Mon Sep 17 00:00:00 2001 From: Johannes Zottele Date: Tue, 12 Aug 2025 17:37:39 +0200 Subject: [PATCH 03/14] grpc-native: Working version of NativeClientCall Signed-off-by: Johannes Zottele --- cinterop-c/include/grpcpp_c.h | 5 +- cinterop-c/src/grpcpp_c.cpp | 1 + .../src/commonTest/proto/helloworld.proto | 14 + .../nativeInterop/cinterop/libgrpcpp_c.def | 7 +- .../kotlinx/rpc/grpc/ManagedChannel.native.kt | 97 +++++-- .../rpc/grpc/internal/ClientCall.native.kt | 4 + .../rpc/grpc/internal/CompletionQueue.kt | 57 +++- .../rpc/grpc/internal/NativeClientCall.kt | 256 ++++++++++++++++++ .../kotlin/kotlinx/rpc/grpc/internal/utils.kt | 133 ++++++++- .../kotlinx/rpc/grpc/internal/CoreTest.kt | 166 +++++------- 10 files changed, 607 insertions(+), 133 deletions(-) create mode 100644 grpc/grpc-core/src/commonTest/proto/helloworld.proto create mode 100644 grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeClientCall.kt diff --git a/cinterop-c/include/grpcpp_c.h b/cinterop-c/include/grpcpp_c.h index 6df6ffee4..92aa21283 100644 --- a/cinterop-c/include/grpcpp_c.h +++ b/cinterop-c/include/grpcpp_c.h @@ -47,7 +47,6 @@ typedef struct { } grpc_cb_tag; - grpc_client_t *grpc_client_create_insecure(const char *target); void grpc_client_delete(const grpc_client_t *client); @@ -77,6 +76,10 @@ typedef struct grpc_channel_credentials grpc_channel_credentials_t; bool kgrpc_iomgr_run_in_background(); + +/////// UTILS /////// + + #ifdef __cplusplus } #endif diff --git a/cinterop-c/src/grpcpp_c.cpp b/cinterop-c/src/grpcpp_c.cpp index 5d5b9ec4b..b06e82ca2 100644 --- a/cinterop-c/src/grpcpp_c.cpp +++ b/cinterop-c/src/grpcpp_c.cpp @@ -208,6 +208,7 @@ extern "C" { return grpc_iomgr_run_in_background(); } + } diff --git a/grpc/grpc-core/src/commonTest/proto/helloworld.proto b/grpc/grpc-core/src/commonTest/proto/helloworld.proto new file mode 100644 index 000000000..f3f815255 --- /dev/null +++ b/grpc/grpc-core/src/commonTest/proto/helloworld.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + + +// The request message containing the user's name. +message HelloRequest { + string name = 1; + optional uint32 timeout = 2; +} + + +// The response message containing the greetings +message HelloReply { + string message = 1; +} diff --git a/grpc/grpc-core/src/nativeInterop/cinterop/libgrpcpp_c.def b/grpc/grpc-core/src/nativeInterop/cinterop/libgrpcpp_c.def index b24317305..7fb8d1482 100644 --- a/grpc/grpc-core/src/nativeInterop/cinterop/libgrpcpp_c.def +++ b/grpc/grpc-core/src/nativeInterop/cinterop/libgrpcpp_c.def @@ -1,7 +1,8 @@ -headers = grpcpp_c.h grpc/grpc.h grpc/credentials.h -headerFilter= grpcpp_c.h grpc/slice.h grpc/byte_buffer.h grpc/grpc.h grpc/impl/grpc_types.h grpc/credentials.h grpc/support/time.h +headers = grpcpp_c.h grpc/grpc.h grpc/credentials.h grpc/byte_buffer_reader.h +headerFilter= grpcpp_c.h grpc/slice.h grpc/byte_buffer.h grpc/grpc.h \ + grpc/impl/grpc_types.h grpc/credentials.h grpc/support/time.h grpc/byte_buffer_reader.h noStringConversion = grpc_slice_from_copied_buffer my_grpc_slice_from_copied_buffer -strictEnums = grpc_status_code, grpc_connectivity_state grpc_call_error +strictEnums = grpc_status_code grpc_connectivity_state grpc_call_error staticLibraries = libgrpcpp_c_static.a 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 e708bbb34..c26b82789 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 @@ -10,10 +10,8 @@ package kotlinx.rpc.grpc import cnames.structs.grpc_channel import kotlinx.cinterop.CPointer import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.rpc.grpc.internal.ClientCall -import kotlinx.rpc.grpc.internal.GrpcCallOptions -import kotlinx.rpc.grpc.internal.GrpcChannel -import kotlinx.rpc.grpc.internal.MethodDescriptor +import kotlinx.coroutines.* +import kotlinx.rpc.grpc.internal.* import libgrpcpp_c.* import kotlin.experimental.ExperimentalNativeApi import kotlin.native.ref.createCleaner @@ -34,13 +32,15 @@ public actual abstract class ManagedChannelBuilder> } internal actual fun ManagedChannelBuilder<*>.buildChannel(): ManagedChannel { - return NativeManagedChannel( - target = "localhost:50051", - credentials = GrpcCredentials( - grpc_insecure_credentials_create() - ?: error("Failed to create credentials") - ) - ) + TODO("Not yet implemented") +// return NativeManagedChannel( +// target = "localhost:50051", +// credentials = GrpcCredentials( +// grpc_insecure_credentials_create() +// ?: error("Failed to create credentials") +// ), +// +// ) } internal actual fun ManagedChannelBuilder(hostname: String, port: Int): ManagedChannelBuilder<*> { @@ -52,35 +52,71 @@ internal actual fun ManagedChannelBuilder(target: String): ManagedChannelBuilder } +private const val GRPC_PROPAGATE_DEFAULTS = 0x0000FFFFu + internal class NativeManagedChannel( - private val target: String, + target: String, // we must store them, otherwise the credentials are getting released - private val credentials: GrpcCredentials, + credentials: GrpcCredentials, + dispatcher: CoroutineDispatcher = Dispatchers.Default, ) : ManagedChannel, ManagedChannelPlatform() { + private val channelJob = SupervisorJob() + private val callJobSupervisor = SupervisorJob(channelJob) + private val channelScope = CoroutineScope(channelJob + dispatcher) + + // the channel's completion queue, handling all request operations + private val cq = CompletionQueue() + internal val raw: CPointer = grpc_channel_create(target, credentials.raw, null) ?: error("Failed to create channel") + + @Suppress("unused") private val rawCleaner = createCleaner(raw) { grpc_channel_destroy(it) } override val platformApi: ManagedChannelPlatform = this - override val isShutdown: Boolean - get() = TODO("Not yet implemented") + private var isShutdownInternal: Boolean = false + override val isShutdown: Boolean = isShutdownInternal + private var isTerminatedInternal = CompletableDeferred(Unit) override val isTerminated: Boolean - get() = TODO("Not yet implemented") + get() = isTerminatedInternal.isCompleted override suspend fun awaitTermination(duration: Duration): Boolean { - TODO("Not yet implemented") + withTimeoutOrNull(duration) { + isTerminatedInternal.await() + } ?: return false + return true } override fun shutdown(): ManagedChannel { - TODO("Not yet implemented") + channelScope.launch { + shutdownInternal(false) + } + return this } override fun shutdownNow(): ManagedChannel { - TODO("Not yet implemented") + channelScope.launch { + shutdownInternal(true) + } + return this + } + + private suspend fun shutdownInternal(force: Boolean) { + isShutdownInternal = true + if (force) { + callJobSupervisor.cancelChildren(CancellationException("Channel is shutting down")) + } + // prevent any start() calls on already created jobs + callJobSupervisor.complete() + cq.shutdown(force) + // wait for child jobs to complete. + // should be immediate, as the completion queue is shutdown. + callJobSupervisor.join() + isTerminatedInternal.complete(Unit) } @@ -88,7 +124,21 @@ internal class NativeManagedChannel( methodDescriptor: MethodDescriptor, callOptions: GrpcCallOptions, ): ClientCall { - TODO("Not yet implemented") + check(!isShutdown) { "Channel is shutdown" } + + val parent = channelScope.coroutineContext[Job]!! + val callJob = Job(parent) + val callScope = CoroutineScope(callJob) + + val methodNameSlice = methodDescriptor.getFullMethodName().toGrpcSlice() + val rawCall = grpc_channel_create_call( + channel = raw, parent_call = null, propagation_mask = GRPC_PROPAGATE_DEFAULTS, completion_queue = cq.raw, + method = methodNameSlice, host = null, deadline = gpr_inf_future(GPR_CLOCK_REALTIME), reserved = null + ) ?: error("Failed to create call") + + return NativeClientCall( + cq, rawCall, methodDescriptor, callScope + ) } override fun authority(): String { @@ -98,10 +148,15 @@ internal class NativeManagedChannel( } -internal class GrpcCredentials( +internal sealed class GrpcCredentials( internal val raw: CPointer, ) { val rawCleaner = createCleaner(raw) { grpc_channel_credentials_release(it) } } + +internal class GrpcInsecureCredentials() : + GrpcCredentials(grpc_insecure_credentials_create() ?: error("Failed to create credentials")) + + 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 aa8bb5c43..f650f9e17 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 @@ -2,11 +2,15 @@ * Copyright 2023-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. */ +@file:OptIn(ExperimentalForeignApi::class, ExperimentalNativeApi::class) + package kotlinx.rpc.grpc.internal +import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.rpc.grpc.GrpcTrailers import kotlinx.rpc.grpc.Status import kotlinx.rpc.internal.utils.InternalRpcApi +import kotlin.experimental.ExperimentalNativeApi @InternalRpcApi public actual abstract class ClientCall { 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 e5ffe9ac0..c3ccd5ba4 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 @@ -9,15 +9,23 @@ package kotlinx.rpc.grpc.internal import cnames.structs.grpc_call import kotlinx.atomicfu.atomic import kotlinx.cinterop.* +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext import libgrpcpp_c.* import platform.posix.memset import kotlin.coroutines.Continuation import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException import kotlin.experimental.ExperimentalNativeApi import kotlin.native.ref.createCleaner +internal sealed interface BatchResult { + object Success : BatchResult + object ResultError : BatchResult + object CQShutdown : BatchResult + data class CallError(val error: grpc_call_error) : BatchResult +} + /** * A coroutine wrapper around the grpc completion_queue, which manages message operations. * It is based on the "new" callback API; therefore, there are no kotlin-side threads required to poll @@ -27,6 +35,9 @@ internal class CompletionQueue { private enum class State { OPEN, SHUTTING_DOWN, CLOSED } + // if the queue was called with forceShutdown = true, + // it will reject all new batches and wait for all current ones to finish. + private var forceShutdown = false private val state = atomic(State.OPEN) // internal as it must be accessible from the SHUTDOWN_CB, @@ -47,7 +58,10 @@ internal class CompletionQueue { val raw = grpc_completion_queue_create_for_callback(shutdownFunctor.ptr, null) + @Suppress("unused") private val thisStableRefCleaner = createCleaner(thisStableRef) { it.dispose() } + + @Suppress("unused") private val shutdownFunctorCleaner = createCleaner(shutdownFunctor) { nativeHeap.free(it) } init { @@ -56,16 +70,22 @@ internal class CompletionQueue { require(kgrpc_iomgr_run_in_background()) { "The gRPC iomgr is not running background threads, required for callback based APIs." } } - suspend fun runBatch(call: CPointer, ops: CPointer, nOps: ULong) = - suspendCancellableCoroutine { cont -> + // TODO: Remove this method + suspend fun runBatch(call: NativeClientCall<*, *>, ops: CPointer, nOps: ULong) = + runBatch(call.raw, ops, nOps) + + suspend fun runBatch(call: CPointer, ops: CPointer, nOps: ULong): BatchResult = + suspendCancellableCoroutine { cont -> val tag = newCbTag(cont, OPS_COMPLETE_CB) var err = grpc_call_error.GRPC_CALL_ERROR // synchronizes access to grpc_call_start_batch withBatchStartLock { - if (state.value != State.OPEN) { + if (forceShutdown || state.value == State.CLOSED) { + // if the queue is either closed or in the process of a FORCE shutdown, + // new batches will instantly fail. deleteCbTag(tag) - cont.resume(grpc_call_error.GRPC_CALL_ERROR_COMPLETION_QUEUE_SHUTDOWN) + cont.resume(BatchResult.CQShutdown) return@suspendCancellableCoroutine } @@ -75,7 +95,7 @@ internal class CompletionQueue { if (err != grpc_call_error.GRPC_CALL_OK) { // if the call was not successful, the callback will not be invoked. deleteCbTag(tag) - cont.resume(err) + cont.resume(BatchResult.CallError(grpc_call_error.GRPC_CALL_ERROR)) return@suspendCancellableCoroutine } @@ -87,16 +107,29 @@ internal class CompletionQueue { // cancel the call if one of its batches is canceled. // grpc_call_cancel is thread-safe and can be called several times. // the callback is invoked anyway, so the tag doesn't get deleted here. - grpc_call_cancel(call, null) + if (it != null) { + grpc_call_cancel_with_status( + call, + grpc_status_code.GRPC_STATUS_CANCELLED, + "Call got cancelled: ${it.message}", + null + ) + } else { + grpc_call_cancel(call, null) + } } } - suspend fun shutdown() { + // must not be canceled as it cleans resources and sets the state to CLOSED + suspend fun shutdown(force: Boolean = false) = withContext(NonCancellable) { + if (force) { + forceShutdown = true + } if (!state.compareAndSet(State.OPEN, State.SHUTTING_DOWN)) { // the first call to shutdown() makes transition and to SHUTTING_DOWN and // initiates shut down. all other invocations await the shutdown. _shutdownDone.await() - return + return@withContext } // wait until all batch operations since the state transitions were started. @@ -126,10 +159,10 @@ internal class CompletionQueue { @CName("kq_ops_complete_cb") private fun opsCompleteCb(functor: CPointer?, ok: Int) { val tag = functor!!.reinterpret() - val cont = tag.pointed.user_data!!.asStableRef>().get() + val cont = tag.pointed.user_data!!.asStableRef>().get() deleteCbTag(tag) - if (ok != 0) cont.resume(grpc_call_error.GRPC_CALL_OK) - else cont.resumeWithException(IllegalStateException("batch failed")) + if (ok != 0) cont.resume(BatchResult.Success) + else cont.resume(BatchResult.ResultError) } @CName("kq_shutdown_cb") 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 new file mode 100644 index 000000000..32a0fe095 --- /dev/null +++ b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeClientCall.kt @@ -0,0 +1,256 @@ +/* + * Copyright 2023-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +@file:OptIn(ExperimentalForeignApi::class, ExperimentalNativeApi::class) + +package kotlinx.rpc.grpc.internal + +import cnames.structs.grpc_call +import kotlinx.atomicfu.atomic +import kotlinx.cinterop.* +import kotlinx.coroutines.* +import kotlinx.io.Buffer +import kotlinx.rpc.grpc.GrpcTrailers +import kotlinx.rpc.grpc.Status +import kotlinx.rpc.grpc.StatusCode +import libgrpcpp_c.* +import kotlin.experimental.ExperimentalNativeApi +import kotlin.native.ref.createCleaner + +internal class NativeClientCall( + private val cq: CompletionQueue, + internal val raw: CPointer, + private val methodDescriptor: MethodDescriptor, + private val callScope: CoroutineScope, +) : ClientCall() { + + @Suppress("unused") + private val rawCleaner = createCleaner(raw) { + grpc_call_unref(it) + } + + // the callJob is completed by the channel on shutdown to prevent start() calls after shutdown + private val callJob = callScope.coroutineContext[Job]!! + + private var listener: Listener? = null + private var halfClosed = false + private var cancelled = false + private var closed = atomic(false) + + override fun start( + responseListener: Listener, + headers: GrpcTrailers, + ) { + check(listener == null) { "Already started" } + check(!closed.value) { "Already closed." } + check(!cancelled) { "Already cancelled." } + // callJob is completed by the channel on shutdown to prevent start calls after shutdown + check(callJob.isActive) { "Call is cancelled or completed." } + + listener = responseListener + + // we directly launch the receiveStatus() operation, which will finish ones the call is finished + callScope.launch { receiveStatus() } + .invokeOnCompletion { + when (it) { + null -> { /* nothing to do */ + } + + is CancellationException -> closeCall( + Status(StatusCode.CANCELLED, "Call got cancelled."), + GrpcTrailers() + ) + + else -> closeCall(Status(StatusCode.INTERNAL, "Call failed.", it), GrpcTrailers()) + } + } + + callScope.launch { + withArena { arena -> + val opsNum = 2uL + val ops = arena.allocArray(opsNum.convert()) + + // send initial meta data to server + // TODO: initial metadata + ops[0].op = GRPC_OP_SEND_INITIAL_METADATA + ops[0].data.send_initial_metadata.count = 0u + + val meta = arena.alloc() + // TODO: make metadata array an object (for lifecycle management) + grpc_metadata_array_init(meta.ptr) + ops[1].op = GRPC_OP_RECV_INITIAL_METADATA + ops[1].data.recv_initial_metadata.recv_initial_metadata = meta.ptr + + runBatch(ops, opsNum) { + // TODO: Send headers to listener + } + + // TODO: destroy with metadata array wrapper (maybe using .use{} ) + grpc_metadata_array_destroy(meta.ptr) + } + } + } + + private suspend fun runBatch( + ops: CPointer, + nOps: ULong, + onSuccess: suspend () -> Unit = {}, + ) { + when (val result = cq.runBatch(this, ops, nOps)) { + BatchResult.Success -> onSuccess() + BatchResult.ResultError -> { + // do nothing, the client will receive the status from the completion queue + } + + BatchResult.CQShutdown -> { + cancelInternal(grpc_status_code.GRPC_STATUS_UNAVAILABLE, "Channel shutdown") + } + + is BatchResult.CallError -> { + cancelInternal(grpc_status_code.GRPC_STATUS_INTERNAL, "Batch could not be submitted: ${result.error}") + } + } + } + + private suspend fun receiveStatus() = withContext(NonCancellable) { + withArena { arena -> + checkNotNull(listener) { "Not yet started" } + // this must not be canceled as it sets the call status. + // if the client itself got canceled, this will return fast. + val statusCode = arena.alloc() + val statusDetails = arena.alloc() + val errorStr = arena.alloc>() + val op = arena.alloc { + op = GRPC_OP_RECV_STATUS_ON_CLIENT + data.recv_status_on_client.status = statusCode.ptr + data.recv_status_on_client.status_details = statusDetails.ptr + data.recv_status_on_client.error_string = errorStr.ptr + // TODO: trailing metadata + data.recv_status_on_client.trailing_metadata = null + } + + // will never fail + cq.runBatch(this@NativeClientCall, op.ptr, 1u) + + val status = Status(errorStr.value?.toKString(), statusCode.value.toKotlin(), null) + val trailers = GrpcTrailers() + closeCall(status, trailers) + } + } + + override fun request(numMessages: Int) { + val listener = checkNotNull(listener) { "Not yet started" } + check(!cancelled) { "Already cancelled" } + check(!closed.value) { "Already closed." } + + callScope.launch { + repeat(numMessages) { + withArena { arena -> + val recvBufferPtr = arena.alloc>() + + val op = arena.alloc() { + op = GRPC_OP_RECV_MESSAGE + data.recv_message.recv_message = recvBufferPtr.ptr + } + + runBatch(op.ptr, 1u) { + val recvBuf = recvBufferPtr.value + if (recvBuf == null) { + println("No more messages to receive") + // TODO: what if we have no more messages to receive? + } else { + val messageBuffer = recvBuf.toKotlin() + val message = methodDescriptor.getResponseMarshaller() + .parse(messageBuffer.asInputStream()) + listener.onMessage(message) + } + } + } + } + }.checkNotCancelled() + + } + + override fun cancel(message: String?, cause: Throwable?) { + cancelled = true + if (message != null) { + grpc_call_cancel(raw, null) + } else { + val message = if (cause != null) "$message: ${cause.message}" else message + cancelInternal(grpc_status_code.GRPC_STATUS_CANCELLED, message ?: "Call cancelled") + } + } + + private fun cancelInternal(statusCode: grpc_status_code, message: String) { + grpc_call_cancel_with_status(raw, statusCode, message, null) + } + + override fun halfClose() { + check(!halfClosed) { "Already half closed." } + check(!cancelled) { "Already cancelled." } + check(!closed.value) { "Already closed." } + halfClosed = true + + callScope.launch { + withArena { arena -> + val op = arena.alloc() + op.op = GRPC_OP_SEND_CLOSE_FROM_CLIENT + + runBatch(op.ptr, 1u) { + // nothing to do here + } + } + }.checkNotCancelled() + } + + override fun sendMessage(message: Request) { + checkNotNull(listener) { "Not yet started" } + check(!halfClosed) { "Already half closed." } + check(!closed.value) { "Already closed." } + + callScope.launch { + withArena { arena -> + val inputStream = methodDescriptor.getRequestMarshaller().stream(message) + // TODO: handle non-byte buffer InputStream sources + val byteBuffer = (inputStream.source as Buffer).toGrpcByteBuffer() + + try { + val op = arena.alloc { + op = GRPC_OP_SEND_MESSAGE + data.send_message.send_message = byteBuffer + } + + runBatch(op.ptr, 1u) { + // Nothing to do here + } + + } finally { + grpc_byte_buffer_destroy(byteBuffer) + } + } + }.checkNotCancelled() + } + + private fun closeCall(status: Status, trailers: GrpcTrailers) { + // only one close call must proceed + if (closed.compareAndSet(expect = false, update = true)) { + val listener = checkNotNull(listener) { "Not yet started" } + listener.onClose(status, trailers) + } + } + + private fun Job.checkNotCancelled() { + invokeOnCompletion { + if (it is CancellationException) { + if (callJob.isCancelled) { + error("Call was already cancelled.") + } else if (callJob.isCompleted) { + error("Call was already closed.") + } + } + } + } +} + + 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 5eaf337e3..008f53f2c 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 @@ -6,8 +6,14 @@ package kotlinx.rpc.grpc.internal -import kotlinx.cinterop.Arena -import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.* +import kotlinx.io.Buffer +import kotlinx.io.Source +import kotlinx.io.UnsafeIoApi +import kotlinx.io.unsafe.UnsafeBufferOperations +import kotlinx.rpc.grpc.StatusCode +import libgrpcpp_c.* +import platform.posix.memcpy internal suspend fun withArena(block: suspend (Arena) -> Unit) = Arena().let { arena -> @@ -18,3 +24,126 @@ internal suspend fun withArena(block: suspend (Arena) -> Unit) = } } +internal fun Buffer.asInputStream(): InputStream = object : InputStream(this) {} + +internal fun CPointer.toKotlin(destroy: Boolean = true): Buffer = memScoped { + val reader = alloc() + check(grpc_byte_buffer_reader_init(reader.ptr, this@toKotlin) == 1) + { "Failed to initialized byte buffer." } + + val out = Buffer() + val slice = alloc() + while (grpc_byte_buffer_reader_next(reader.ptr, slice.ptr) != 0) { + val dataPtr = slice.startPtr() + val len = slice.len() + + out.writeFully(dataPtr, 0, len.convert()) + grpc_slice_unref(slice.readValue()) + } + + grpc_byte_buffer_reader_destroy(reader.ptr) + if (destroy) { + grpc_byte_buffer_destroy(this@toKotlin) + } + + return out +} + + +internal fun Source.toGrpcByteBuffer(): CPointer { + if (this is Buffer) return toGrpcByteBuffer() + + val tmp = ByteArray(8192) + val slices = ArrayList>(4) + + while (true) { + val n = readAtMostTo(tmp, 0, tmp.size) + if (n <= 0) break + tmp.usePinned { + slices += grpc_slice_from_copied_buffer(it.addressOf(0), n.toULong()) + } + } + + return slices.toGrpcByteBuffer() +} + +@OptIn(UnsafeIoApi::class) +internal fun Buffer.toGrpcByteBuffer(): CPointer { + val slices = ArrayList>(4) + + while (size > 0L) { + UnsafeBufferOperations.readFromHead(this) { arr, start, end -> + val len = end - start + arr.usePinned { p -> slices += grpc_slice_from_copied_buffer(p.addressOf(start), len.toULong()) } + len + } + } + + return slices.toGrpcByteBuffer() +} + +private fun ArrayList>.toGrpcByteBuffer(): CPointer = memScoped { + val count = if (isEmpty()) 1 else size + val sliceArr = allocArray(count) + val base = sliceArr.reinterpret() + val stride = sizeOf() + + if (isEmpty()) { + val dst = base /* + 0*stride */ + val empty = grpc_slice_malloc(0u) + empty.useContents { memcpy(dst, ptr, stride.convert()) } + } else { + for (i in 0 until count) { + val dst = base + i * stride // <-- important: advance by i*size + this@toGrpcByteBuffer[i].useContents { memcpy(dst, ptr, stride.convert()) } + } + } + + + val buf = grpc_raw_byte_buffer_create(sliceArr, count.toULong())!! + // unref each slice, as the buffer takes ownership + this@toGrpcByteBuffer.forEach { grpc_slice_unref(it) } + + return buf +} + +internal fun grpc_slice.startPtr(): CPointer { + return if (this.refcount != null) { + this.data.refcounted.bytes!!.reinterpret() + } else { + this.data.inlined.bytes.reinterpret() + } +} + +internal fun grpc_slice.len(): ULong { + return if (this.refcount != null) { + this.data.refcounted.length + } else { + this.data.inlined.length.convert() + } +} + +internal fun String.toGrpcSlice(): CValue { + return grpc_slice_from_copied_string(this) +} + +internal fun grpc_status_code.toKotlin(): StatusCode = when (this) { + grpc_status_code.GRPC_STATUS_OK -> StatusCode.OK + grpc_status_code.GRPC_STATUS_CANCELLED -> StatusCode.CANCELLED + grpc_status_code.GRPC_STATUS_UNKNOWN -> StatusCode.UNKNOWN + grpc_status_code.GRPC_STATUS_INVALID_ARGUMENT -> StatusCode.INVALID_ARGUMENT + grpc_status_code.GRPC_STATUS_DEADLINE_EXCEEDED -> StatusCode.DEADLINE_EXCEEDED + grpc_status_code.GRPC_STATUS_NOT_FOUND -> StatusCode.NOT_FOUND + grpc_status_code.GRPC_STATUS_ALREADY_EXISTS -> StatusCode.ALREADY_EXISTS + grpc_status_code.GRPC_STATUS_PERMISSION_DENIED -> StatusCode.PERMISSION_DENIED + grpc_status_code.GRPC_STATUS_RESOURCE_EXHAUSTED -> StatusCode.RESOURCE_EXHAUSTED + grpc_status_code.GRPC_STATUS_FAILED_PRECONDITION -> StatusCode.FAILED_PRECONDITION + grpc_status_code.GRPC_STATUS_ABORTED -> StatusCode.ABORTED + grpc_status_code.GRPC_STATUS_OUT_OF_RANGE -> StatusCode.OUT_OF_RANGE + grpc_status_code.GRPC_STATUS_UNIMPLEMENTED -> StatusCode.UNIMPLEMENTED + grpc_status_code.GRPC_STATUS_INTERNAL -> StatusCode.INTERNAL + grpc_status_code.GRPC_STATUS_UNAVAILABLE -> StatusCode.UNAVAILABLE + grpc_status_code.GRPC_STATUS_DATA_LOSS -> StatusCode.DATA_LOSS + grpc_status_code.GRPC_STATUS_UNAUTHENTICATED -> StatusCode.UNAUTHENTICATED + grpc_status_code.GRPC_STATUS__DO_NOT_USE -> error("Invalid status code: ${this.ordinal}") +} \ No newline at end of file diff --git a/grpc/grpc-core/src/nativeTest/kotlin/kotlinx/rpc/grpc/internal/CoreTest.kt b/grpc/grpc-core/src/nativeTest/kotlin/kotlinx/rpc/grpc/internal/CoreTest.kt index 73d9f0674..4598113b0 100644 --- a/grpc/grpc-core/src/nativeTest/kotlin/kotlinx/rpc/grpc/internal/CoreTest.kt +++ b/grpc/grpc-core/src/nativeTest/kotlin/kotlinx/rpc/grpc/internal/CoreTest.kt @@ -6,121 +6,99 @@ package kotlinx.rpc.grpc.internal -import kotlinx.cinterop.* -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch +import HelloReply +import HelloReplyInternal +import HelloRequest +import HelloRequestInternal +import invoke +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking -import libgrpcpp_c.* +import kotlinx.coroutines.withTimeout +import kotlinx.rpc.grpc.* +import libgrpcpp_c.grpc_init +import libgrpcpp_c.grpc_shutdown import kotlin.experimental.ExperimentalNativeApi import kotlin.test.Test -import kotlin.test.fail class GrpcCoreTest { val GRPC_PROPAGATE_DEFAULTS = 0x0000FFFFu - suspend fun doCall(reqBytes: ByteArray) = withArena { arena -> - val cq = CompletionQueue() - - val creds = grpc_insecure_credentials_create()!! - val channel = grpc_channel_create("localhost:50051", creds, null)!! - - val method = grpc_slice_from_copied_string("/helloworld.Greeter/SayHello") - - val call = grpc_channel_create_call( - channel, null, GRPC_PROPAGATE_DEFAULTS, cq.raw, - method, null, gpr_inf_future(GPR_CLOCK_REALTIME), null - ) - - println("Request bytes: ${reqBytes.toHexString()}") - - // make a grpc_slice from bytes (copied buffer) - val reqSlice = memScoped { - val ptr = allocArray(reqBytes.size) - for (i in reqBytes.indices) ptr[i] = reqBytes[i] - grpc_slice_from_copied_buffer(ptr, reqBytes.size.toULong()) + internal fun runHelloWorld(block: suspend (ManagedChannel, ClientCall) -> Unit) = + runBlocking { + grpc_init() + + val fullName = "/helloworld.Greeter/SayHello" + val descriptor = methodDescriptor( + fullMethodName = fullName, + requestCodec = HelloRequestInternal.CODEC, + responseCodec = HelloReplyInternal.CODEC, + type = MethodType.UNARY, + schemaDescriptor = Unit, + idempotent = true, + safe = true, + sampledToLocalTracing = true, + ) + val channel = NativeManagedChannel( + "localhost:50051", + GrpcInsecureCredentials(), + ) + + try { + val call = channel.newCall(descriptor, GrpcCallOptions()) + block(channel, call) + + } finally { + channel.shutdown() + grpc_shutdown() + } } - val reqSlicePtr = reqSlice.getPointer(arena) - val req_buf = grpc_raw_byte_buffer_create(reqSlicePtr, 1u) - - // Use a single batch (no RECV_INITIAL_METADATA to keep it minimal) - val ops = arena.allocArray(6) - - // SEND_INITIAL_METADATA - ops[0].op = GRPC_OP_SEND_INITIAL_METADATA - ops[0].data.send_initial_metadata.count = 0u - - // SEND_MESSAGE - ops[1].op = GRPC_OP_SEND_MESSAGE - ops[1].data.send_message.send_message = req_buf - - // SEND_CLOSE_FROM_CLIENT - ops[2].op = GRPC_OP_SEND_CLOSE_FROM_CLIENT - - - val meta = arena.alloc() - grpc_metadata_array_init(meta.ptr) - ops[3].op = GRPC_OP_RECV_INITIAL_METADATA - ops[3].data.recv_initial_metadata.recv_initial_metadata = meta.ptr - - // RECV_MESSAGE -> grpc_byte_buffer** - val recvBufPtr = arena.alloc>() - ops[4].op = GRPC_OP_RECV_MESSAGE - ops[4].data.recv_message.recv_message = recvBufPtr.ptr - - // RECV_STATUS_ON_CLIENT - val statusCode = arena.alloc() - val statusDetails = arena.alloc() - val errorStr = arena.alloc>() - ops[5].op = GRPC_OP_RECV_STATUS_ON_CLIENT - ops[5].data.recv_status_on_client.status = statusCode.ptr - ops[5].data.recv_status_on_client.status_details = statusDetails.ptr - ops[5].data.recv_status_on_client.error_string = errorStr.ptr - // trailing metadata is optional; leave it null if not used - + @Test + fun grpcClientTest() = runHelloWorld { channel, call -> + val req = HelloRequest { + name = "world" + timeout = 0u + } - coroutineScope { + val sem = CompletableDeferred() + val helloReply = CompletableDeferred() - launch { - println("Start continuation call") - cq.runBatch(call!!, ops, 6u) - println("Call continuation done") + val listener = object : ClientCall.Listener() { + override fun onMessage(message: HelloReply) { + helloReply.complete(message) } - launch { - println("Shutting down") - cq.shutdown() - println("Shutdown") + override fun onClose(status: Status, trailers: GrpcTrailers) { + sem.complete(status) } } - - println("Status code: ${statusCode.value}") - println("Error string: ${errorStr.value?.toKString()}") - if (statusCode.value != GRPC_STATUS_OK) { - fail("Call failed with status code ${statusCode.value}") + call.start(listener, GrpcTrailers()) + call.sendMessage(req) + call.halfClose() + delay(1) + call.request(1) + + channel.shutdown() + + withTimeout(10000) { + val status = sem.await() + val helloReply = helloReply.await() + println("status: ${status.statusCode} (${status.getDescription()})") + println("helloReply: $helloReply") + assert(status.statusCode == StatusCode.OK) + assert(helloReply.message == "Hello world") } - } - @Test - fun grpcCoreDemo() = memScoped { - - grpc_init() - - // --- build protobuf HelloRequest { name = "world" } --- - // field 1 (tag=1, wire=2) => key = 0x0A - val name = "world".encodeToByteArray() - val reqBytes = ByteArray(2 + name.size).apply { - this[0] = 0x0A // field 1, length-delimited - this[1] = name.size.toByte() // length (assumes <128) - name.copyInto(this, 2) - } - runBlocking { - doCall(reqBytes) + @Test + fun testNormalOften() { + for (i in 0..1000) { + grpcClientTest() } - grpc_shutdown() } } \ No newline at end of file From ebc0efd1f2a401407c71995700180e45921e1f08 Mon Sep 17 00:00:00 2001 From: Johannes Zottele Date: Thu, 14 Aug 2025 09:08:57 +0200 Subject: [PATCH 04/14] grpc-native: Working rewrite state Signed-off-by: Johannes Zottele --- .../kotlinx/rpc/grpc/ManagedChannel.native.kt | 48 +-- .../rpc/grpc/internal/CompletionQueue.kt | 118 +++----- .../rpc/grpc/internal/NativeClientCall.kt | 273 +++++++++--------- .../rpc/grpc/internal/NativeGrpcLibrary.kt | 42 +++ .../kotlinx/rpc/grpc/internal/CoreTest.kt | 273 ++++++++++++++---- 5 files changed, 473 insertions(+), 281 deletions(-) create mode 100644 grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeGrpcLibrary.kt 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 c26b82789..036898527 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 @@ -61,6 +61,10 @@ internal class NativeManagedChannel( dispatcher: CoroutineDispatcher = Dispatchers.Default, ) : ManagedChannel, ManagedChannelPlatform() { + // A reference to make sure the grpc_init() was called. (it is released after shutdown) + @Suppress("unused") + private val rt = GrpcRuntime.acquire() + private val channelJob = SupervisorJob() private val callJobSupervisor = SupervisorJob(channelJob) private val channelScope = CoroutineScope(channelJob + dispatcher) @@ -80,7 +84,7 @@ internal class NativeManagedChannel( private var isShutdownInternal: Boolean = false override val isShutdown: Boolean = isShutdownInternal - private var isTerminatedInternal = CompletableDeferred(Unit) + private var isTerminatedInternal = CompletableDeferred() override val isTerminated: Boolean get() = isTerminatedInternal.isCompleted @@ -92,43 +96,49 @@ internal class NativeManagedChannel( } override fun shutdown(): ManagedChannel { - channelScope.launch { - shutdownInternal(false) - } + shutdownInternal(false) return this } override fun shutdownNow(): ManagedChannel { - channelScope.launch { - shutdownInternal(true) - } + shutdownInternal(true) return this } - private suspend fun shutdownInternal(force: Boolean) { + private fun shutdownInternal(force: Boolean) { isShutdownInternal = true + if (isTerminatedInternal.isCompleted) { + return + } if (force) { callJobSupervisor.cancelChildren(CancellationException("Channel is shutting down")) } - // prevent any start() calls on already created jobs + // prevent any start() calls on already call jobs + callJobSupervisor.children.forEach { + (it as CompletableJob).complete() + } callJobSupervisor.complete() - cq.shutdown(force) - // wait for child jobs to complete. - // should be immediate, as the completion queue is shutdown. - callJobSupervisor.join() - isTerminatedInternal.complete(Unit) + channelScope.launch { + withContext(NonCancellable) { + // wait for all child jobs to complete. + callJobSupervisor.join() + // wait for the completion queue to shut down. + cq.shutdown(force).await() + if (isTerminatedInternal.complete(Unit)) { + // release the grpc runtime, so it might call grpc_shutdown() + rt.close() + } + } + } } - override fun newCall( methodDescriptor: MethodDescriptor, callOptions: GrpcCallOptions, ): ClientCall { check(!isShutdown) { "Channel is shutdown" } - val parent = channelScope.coroutineContext[Job]!! - val callJob = Job(parent) - val callScope = CoroutineScope(callJob) + val callJob = Job(callJobSupervisor) val methodNameSlice = methodDescriptor.getFullMethodName().toGrpcSlice() val rawCall = grpc_channel_create_call( @@ -137,7 +147,7 @@ internal class NativeManagedChannel( ) ?: error("Failed to create call") return NativeClientCall( - cq, rawCall, methodDescriptor, callScope + cq, rawCall, methodDescriptor, callJob ) } 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 c3ccd5ba4..64c7d6d19 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 @@ -8,14 +8,12 @@ package kotlinx.rpc.grpc.internal import cnames.structs.grpc_call import kotlinx.atomicfu.atomic +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized import kotlinx.cinterop.* -import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.withContext +import kotlinx.coroutines.CompletableDeferred import libgrpcpp_c.* import platform.posix.memset -import kotlin.coroutines.Continuation -import kotlin.coroutines.resume import kotlin.experimental.ExperimentalNativeApi import kotlin.native.ref.createCleaner @@ -33,20 +31,24 @@ internal sealed interface BatchResult { */ internal class CompletionQueue { - private enum class State { OPEN, SHUTTING_DOWN, CLOSED } + internal enum class State { OPEN, SHUTTING_DOWN, CLOSED } // if the queue was called with forceShutdown = true, // it will reject all new batches and wait for all current ones to finish. private var forceShutdown = false - private val state = atomic(State.OPEN) // internal as it must be accessible from the SHUTDOWN_CB, // but it shouldn't be used from outside this file. @Suppress("PropertyName") - internal val _shutdownDone = kotlinx.coroutines.CompletableDeferred() + internal val _state = atomic(State.OPEN) + + // internal as it must be accessible from the SHUTDOWN_CB, + // but it shouldn't be used from outside this file. + @Suppress("PropertyName") + internal val _shutdownDone = CompletableDeferred() // used for spinning lock. false means not used (available) - private val batchStartGuard = atomic(false) + private val batchStartGuard = SynchronizedObject() private val thisStableRef = StableRef.create(this) @@ -71,87 +73,56 @@ internal class CompletionQueue { } // TODO: Remove this method - suspend fun runBatch(call: NativeClientCall<*, *>, ops: CPointer, nOps: ULong) = + fun runBatch(call: NativeClientCall<*, *>, ops: CPointer, nOps: ULong) = runBatch(call.raw, ops, nOps) - suspend fun runBatch(call: CPointer, ops: CPointer, nOps: ULong): BatchResult = - suspendCancellableCoroutine { cont -> - val tag = newCbTag(cont, OPS_COMPLETE_CB) + fun runBatch(call: CPointer, ops: CPointer, nOps: ULong): CompletableDeferred { + val completion = CompletableDeferred() + val tag = newCbTag(completion, OPS_COMPLETE_CB) - var err = grpc_call_error.GRPC_CALL_ERROR + var err = grpc_call_error.GRPC_CALL_ERROR + synchronized(batchStartGuard) { // synchronizes access to grpc_call_start_batch - withBatchStartLock { - if (forceShutdown || state.value == State.CLOSED) { - // if the queue is either closed or in the process of a FORCE shutdown, - // new batches will instantly fail. - deleteCbTag(tag) - cont.resume(BatchResult.CQShutdown) - return@suspendCancellableCoroutine - } - - err = grpc_call_start_batch(call, ops, nOps, tag, null) - } - - if (err != grpc_call_error.GRPC_CALL_OK) { - // if the call was not successful, the callback will not be invoked. + if (forceShutdown || _state.value == State.CLOSED) { + // if the queue is either closed or in the process of a FORCE shutdown, + // new batches will instantly fail. deleteCbTag(tag) - cont.resume(BatchResult.CallError(grpc_call_error.GRPC_CALL_ERROR)) - return@suspendCancellableCoroutine + completion.complete(BatchResult.CQShutdown) + return completion } + err = grpc_call_start_batch(call, ops, nOps, tag, null) + } - cont.invokeOnCancellation { - @Suppress("UnusedExpression") - // keep reference, otherwise the cleaners might get invoked before the batch finishes - this - // cancel the call if one of its batches is canceled. - // grpc_call_cancel is thread-safe and can be called several times. - // the callback is invoked anyway, so the tag doesn't get deleted here. - if (it != null) { - grpc_call_cancel_with_status( - call, - grpc_status_code.GRPC_STATUS_CANCELLED, - "Call got cancelled: ${it.message}", - null - ) - } else { - grpc_call_cancel(call, null) - } - } + if (err != grpc_call_error.GRPC_CALL_OK) { + // if the call was not successful, the callback will not be invoked. + deleteCbTag(tag) + completion.complete(BatchResult.CallError(err)) + return completion } + return completion + } + // must not be canceled as it cleans resources and sets the state to CLOSED - suspend fun shutdown(force: Boolean = false) = withContext(NonCancellable) { + fun shutdown(force: Boolean = false): CompletableDeferred { if (force) { forceShutdown = true } - if (!state.compareAndSet(State.OPEN, State.SHUTTING_DOWN)) { + if (!_state.compareAndSet(State.OPEN, State.SHUTTING_DOWN)) { // the first call to shutdown() makes transition and to SHUTTING_DOWN and // initiates shut down. all other invocations await the shutdown. - _shutdownDone.await() - return@withContext + return _shutdownDone } // wait until all batch operations since the state transitions were started. - // this is required to prevent batches from starting after shutdown was initialized - withBatchStartLock { } - - grpc_completion_queue_shutdown(raw) - _shutdownDone.await() - state.value = State.CLOSED - } - - private inline fun withBatchStartLock(block: () -> Unit) { - try { - // spin until this thread occupies the guard - @Suppress("ControlFlowWithEmptyBody") - while (!batchStartGuard.compareAndSet(expect = false, update = true)) { - } - block() - } finally { - // set guard to "not occupied" - batchStartGuard.value = false + // this is required to prevent batches from starting after shutdown was initialized. + // however, this lock will be available very fast, so it shouldn't be a problem.' + synchronized(batchStartGuard) { + grpc_completion_queue_shutdown(raw) } + + return _shutdownDone } } @@ -159,10 +130,10 @@ internal class CompletionQueue { @CName("kq_ops_complete_cb") private fun opsCompleteCb(functor: CPointer?, ok: Int) { val tag = functor!!.reinterpret() - val cont = tag.pointed.user_data!!.asStableRef>().get() + val cont = tag.pointed.user_data!!.asStableRef>().get() deleteCbTag(tag) - if (ok != 0) cont.resume(BatchResult.Success) - else cont.resume(BatchResult.ResultError) + if (ok != 0) cont.complete(BatchResult.Success) + else cont.complete(BatchResult.ResultError) } @CName("kq_shutdown_cb") @@ -170,6 +141,7 @@ private fun shutdownCb(functor: CPointer?, ok: In val tag = functor!!.reinterpret() val cq = tag.pointed.user_data!!.asStableRef().get() cq._shutdownDone.complete(Unit) + cq._state.value = CompletionQueue.State.CLOSED grpc_completion_queue_destroy(cq.raw) } 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 32a0fe095..d8d8c40d8 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 @@ -13,16 +13,16 @@ import kotlinx.coroutines.* import kotlinx.io.Buffer import kotlinx.rpc.grpc.GrpcTrailers import kotlinx.rpc.grpc.Status -import kotlinx.rpc.grpc.StatusCode import libgrpcpp_c.* import kotlin.experimental.ExperimentalNativeApi import kotlin.native.ref.createCleaner + internal class NativeClientCall( private val cq: CompletionQueue, internal val raw: CPointer, private val methodDescriptor: MethodDescriptor, - private val callScope: CoroutineScope, + private val callJob: CompletableJob, ) : ClientCall() { @Suppress("unused") @@ -30,8 +30,24 @@ internal class NativeClientCall( grpc_call_unref(it) } - // the callJob is completed by the channel on shutdown to prevent start() calls after shutdown - private val callJob = callScope.coroutineContext[Job]!! + init { + callJob.invokeOnCompletion { + when (it) { + is CancellationException -> { + cancelInternal(grpc_status_code.GRPC_STATUS_CANCELLED, "Call got cancelled.") + } + + is Throwable -> { + cancelInternal(grpc_status_code.GRPC_STATUS_INTERNAL, "Call failed: ${it.message}") + } + } + } + } + + private val callRunJob = SupervisorJob() + private val callRunScope = CoroutineScope(callRunJob) + private val callBatchJob = SupervisorJob(callRunJob) + private val callBatchScope = CoroutineScope(callBatchJob) private var listener: Listener? = null private var halfClosed = false @@ -50,126 +66,128 @@ internal class NativeClientCall( listener = responseListener - // we directly launch the receiveStatus() operation, which will finish ones the call is finished - callScope.launch { receiveStatus() } - .invokeOnCompletion { - when (it) { - null -> { /* nothing to do */ - } - - is CancellationException -> closeCall( - Status(StatusCode.CANCELLED, "Call got cancelled."), - GrpcTrailers() - ) + // start receiving the status from the completion queue, + // which is bound to the lifecycle of the call. + receiveStatus() - else -> closeCall(Status(StatusCode.INTERNAL, "Call failed.", it), GrpcTrailers()) - } - } - callScope.launch { - withArena { arena -> - val opsNum = 2uL - val ops = arena.allocArray(opsNum.convert()) + // sending and receiving initial metadata + val arena = Arena() + val opsNum = 2uL + val ops = arena.allocArray(opsNum.convert()) - // send initial meta data to server - // TODO: initial metadata - ops[0].op = GRPC_OP_SEND_INITIAL_METADATA - ops[0].data.send_initial_metadata.count = 0u + // send initial meta data to server + // TODO: initial metadata + ops[0].op = GRPC_OP_SEND_INITIAL_METADATA + ops[0].data.send_initial_metadata.count = 0u - val meta = arena.alloc() - // TODO: make metadata array an object (for lifecycle management) - grpc_metadata_array_init(meta.ptr) - ops[1].op = GRPC_OP_RECV_INITIAL_METADATA - ops[1].data.recv_initial_metadata.recv_initial_metadata = meta.ptr + val meta = arena.alloc() + // TODO: make metadata array an object (for lifecycle management) + grpc_metadata_array_init(meta.ptr) + ops[1].op = GRPC_OP_RECV_INITIAL_METADATA + ops[1].data.recv_initial_metadata.recv_initial_metadata = meta.ptr - runBatch(ops, opsNum) { - // TODO: Send headers to listener - } - - // TODO: destroy with metadata array wrapper (maybe using .use{} ) - grpc_metadata_array_destroy(meta.ptr) - } + runBatch(ops, opsNum, cleanup = { + grpc_metadata_array_init(meta.ptr) + arena.clear() + }) { + // TODO: Send headers to listener } } - private suspend fun runBatch( + private fun runBatch( ops: CPointer, nOps: ULong, + cleanup: () -> Unit = {}, onSuccess: suspend () -> Unit = {}, ) { - when (val result = cq.runBatch(this, ops, nOps)) { - BatchResult.Success -> onSuccess() - BatchResult.ResultError -> { - // do nothing, the client will receive the status from the completion queue - } + val completion = cq.runBatch(this@NativeClientCall, ops, nOps) + callBatchScope.launch { + when (val result = completion.await()) { + BatchResult.Success -> onSuccess() + BatchResult.ResultError -> { + // do nothing, the client will receive the status from the completion queue + } - BatchResult.CQShutdown -> { - cancelInternal(grpc_status_code.GRPC_STATUS_UNAVAILABLE, "Channel shutdown") - } + BatchResult.CQShutdown -> { + cancelInternal(grpc_status_code.GRPC_STATUS_UNAVAILABLE, "Channel shutdown") + } - is BatchResult.CallError -> { - cancelInternal(grpc_status_code.GRPC_STATUS_INTERNAL, "Batch could not be submitted: ${result.error}") + is BatchResult.CallError -> { + cancelInternal( + grpc_status_code.GRPC_STATUS_INTERNAL, + "Batch could not be submitted: ${result.error}" + ) + } + } + }.invokeOnCompletion { + cleanup() + if (it != null) { + throw IllegalStateException("Unexpected exception during batch.", it) } } } - private suspend fun receiveStatus() = withContext(NonCancellable) { - withArena { arena -> - checkNotNull(listener) { "Not yet started" } - // this must not be canceled as it sets the call status. - // if the client itself got canceled, this will return fast. - val statusCode = arena.alloc() - val statusDetails = arena.alloc() - val errorStr = arena.alloc>() - val op = arena.alloc { - op = GRPC_OP_RECV_STATUS_ON_CLIENT - data.recv_status_on_client.status = statusCode.ptr - data.recv_status_on_client.status_details = statusDetails.ptr - data.recv_status_on_client.error_string = errorStr.ptr - // TODO: trailing metadata - data.recv_status_on_client.trailing_metadata = null - } + private fun receiveStatus() { + checkNotNull(listener) { "Not yet started" } + val arena = Arena() + // this must not be canceled as it sets the call status. + // if the client itself got canceled, this will return fast. + val statusCode = arena.alloc() + val statusDetails = arena.alloc() + val errorStr = arena.alloc>() + val op = arena.alloc { + op = GRPC_OP_RECV_STATUS_ON_CLIENT + data.recv_status_on_client.status = statusCode.ptr + data.recv_status_on_client.status_details = statusDetails.ptr + data.recv_status_on_client.error_string = errorStr.ptr + // TODO: trailing metadata + data.recv_status_on_client.trailing_metadata = null + } - // will never fail - cq.runBatch(this@NativeClientCall, op.ptr, 1u) - val status = Status(errorStr.value?.toKString(), statusCode.value.toKotlin(), null) - val trailers = GrpcTrailers() - closeCall(status, trailers) + val completion = cq.runBatch(this@NativeClientCall, op.ptr, 1u) + callRunScope.launch { + withContext(NonCancellable) { + // will never fail + completion.await() + callBatchJob.complete() + callBatchJob.join() + val status = Status(errorStr.value?.toKString(), statusCode.value.toKotlin(), null) + val trailers = GrpcTrailers() + println("Closing with status $status") + closeCall(status, trailers) + } + }.invokeOnCompletion { + arena.clear() + if (it != null) { + throw IllegalStateException("Unexpected exception during call.", it) + } } } override fun request(numMessages: Int) { + check(numMessages > 0) { "numMessages must be > 0" } val listener = checkNotNull(listener) { "Not yet started" } check(!cancelled) { "Already cancelled" } check(!closed.value) { "Already closed." } - callScope.launch { - repeat(numMessages) { - withArena { arena -> - val recvBufferPtr = arena.alloc>() - - val op = arena.alloc() { - op = GRPC_OP_RECV_MESSAGE - data.recv_message.recv_message = recvBufferPtr.ptr - } - - runBatch(op.ptr, 1u) { - val recvBuf = recvBufferPtr.value - if (recvBuf == null) { - println("No more messages to receive") - // TODO: what if we have no more messages to receive? - } else { - val messageBuffer = recvBuf.toKotlin() - val message = methodDescriptor.getResponseMarshaller() - .parse(messageBuffer.asInputStream()) - listener.onMessage(message) - } - } - } + fun once() { + val arena = Arena() + val recvPtr = arena.alloc>() + val op = arena.alloc { + op = GRPC_OP_RECV_MESSAGE + data.recv_message.recv_message = recvPtr.ptr } - }.checkNotCancelled() - + runBatch(op.ptr, 1u, cleanup = { arena.clear() }) { + val buf = recvPtr.value ?: return@runBatch // EOS + val msg = methodDescriptor.getResponseMarshaller() + .parse(buf.toKotlin().asInputStream()) + listener.onMessage(msg) + once() // post next only now + } + } + once() } override fun cancel(message: String?, cause: Throwable?) { @@ -192,65 +210,46 @@ internal class NativeClientCall( check(!closed.value) { "Already closed." } halfClosed = true - callScope.launch { - withArena { arena -> - val op = arena.alloc() - op.op = GRPC_OP_SEND_CLOSE_FROM_CLIENT + val arena = Arena() + val op = arena.alloc() { + op = GRPC_OP_SEND_CLOSE_FROM_CLIENT + } - runBatch(op.ptr, 1u) { - // nothing to do here - } - } - }.checkNotCancelled() + runBatch(op.ptr, 1u, cleanup = { arena.clear() }) { + // nothing to do here + } } override fun sendMessage(message: Request) { checkNotNull(listener) { "Not yet started" } check(!halfClosed) { "Already half closed." } + check(!cancelled) { "Already cancelled." } check(!closed.value) { "Already closed." } - callScope.launch { - withArena { arena -> - val inputStream = methodDescriptor.getRequestMarshaller().stream(message) - // TODO: handle non-byte buffer InputStream sources - val byteBuffer = (inputStream.source as Buffer).toGrpcByteBuffer() - - try { - val op = arena.alloc { - op = GRPC_OP_SEND_MESSAGE - data.send_message.send_message = byteBuffer - } - - runBatch(op.ptr, 1u) { - // Nothing to do here - } - - } finally { - grpc_byte_buffer_destroy(byteBuffer) - } - } - }.checkNotCancelled() + val arena = Arena() + val inputStream = methodDescriptor.getRequestMarshaller().stream(message) + val byteBuffer = (inputStream.source as Buffer).toGrpcByteBuffer() + val op = arena.alloc { + op = GRPC_OP_SEND_MESSAGE + data.send_message.send_message = byteBuffer + } + runBatch(op.ptr, 1u, cleanup = { + grpc_byte_buffer_destroy(byteBuffer) + arena.clear() + }) { + // Nothing to do here + } } private fun closeCall(status: Status, trailers: GrpcTrailers) { // only one close call must proceed if (closed.compareAndSet(expect = false, update = true)) { val listener = checkNotNull(listener) { "Not yet started" } + println("Closing with status ${status.statusCode}") + callJob.complete() listener.onClose(status, trailers) } } - - private fun Job.checkNotCancelled() { - invokeOnCompletion { - if (it is CancellationException) { - if (callJob.isCancelled) { - error("Call was already cancelled.") - } else if (callJob.isCompleted) { - error("Call was already closed.") - } - } - } - } } diff --git a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeGrpcLibrary.kt b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeGrpcLibrary.kt new file mode 100644 index 000000000..f6fd79ee9 --- /dev/null +++ b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeGrpcLibrary.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2023-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +@file:OptIn(ExperimentalNativeApi::class, ExperimentalForeignApi::class) + +package kotlinx.rpc.grpc.internal + +import kotlinx.atomicfu.atomic +import kotlinx.atomicfu.locks.reentrantLock +import kotlinx.atomicfu.locks.withLock +import kotlinx.cinterop.ExperimentalForeignApi +import libgrpcpp_c.grpc_init +import libgrpcpp_c.grpc_shutdown +import kotlin.experimental.ExperimentalNativeApi + +internal object GrpcRuntime { + private val refLock = reentrantLock() + private var refs = 0 + + /** Acquire a runtime reference. Must be closed exactly once. */ + fun acquire(): AutoCloseable { + refLock.withLock { + val prev = 0 + refs++ + if (prev == 0) grpc_init() + } + return object : AutoCloseable { + private var done = atomic(false) + override fun close() { + if (!done.compareAndSet(expect = false, update = true)) return + refLock.withLock { + val now = --refs + require(now >= 0) { "release() without matching acquire()" } + if (now == 0) { + grpc_shutdown() + } + } + } + } + } +} \ No newline at end of file diff --git a/grpc/grpc-core/src/nativeTest/kotlin/kotlinx/rpc/grpc/internal/CoreTest.kt b/grpc/grpc-core/src/nativeTest/kotlin/kotlinx/rpc/grpc/internal/CoreTest.kt index 4598113b0..0aae600fb 100644 --- a/grpc/grpc-core/src/nativeTest/kotlin/kotlinx/rpc/grpc/internal/CoreTest.kt +++ b/grpc/grpc-core/src/nativeTest/kotlin/kotlinx/rpc/grpc/internal/CoreTest.kt @@ -5,7 +5,6 @@ package kotlinx.rpc.grpc.internal - import HelloReply import HelloReplyInternal import HelloRequest @@ -13,92 +12,262 @@ import HelloRequestInternal import invoke import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout import kotlinx.rpc.grpc.* -import libgrpcpp_c.grpc_init -import libgrpcpp_c.grpc_shutdown import kotlin.experimental.ExperimentalNativeApi import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue class GrpcCoreTest { - val GRPC_PROPAGATE_DEFAULTS = 0x0000FFFFu - internal fun runHelloWorld(block: suspend (ManagedChannel, ClientCall) -> Unit) = + // Helpers to reduce boilerplate across tests + private fun descriptorFor(fullName: String = "/helloworld.Greeter/SayHello"): MethodDescriptor = + methodDescriptor( + fullMethodName = fullName, + requestCodec = HelloRequestInternal.CODEC, + responseCodec = HelloReplyInternal.CODEC, + type = MethodType.UNARY, + schemaDescriptor = Unit, + idempotent = true, + safe = true, + sampledToLocalTracing = true, + ) + + private fun NativeManagedChannel.newHelloCall(fullName: String = "/helloworld.Greeter/SayHello"): ClientCall = + newCall(descriptorFor(fullName), GrpcCallOptions()) + + private fun createChannel(): NativeManagedChannel = + NativeManagedChannel( + "localhost:50051", + GrpcInsecureCredentials(), + ) + + private fun helloReq(timeout: UInt = 0u): HelloRequest = HelloRequest { + name = "world" + this.timeout = timeout + } + + private fun shutdownAndWait(channel: NativeManagedChannel) { + channel.shutdown() + runBlocking { channel.awaitTermination() } + } + + @Test + fun normalUnaryCall_ok() = repeat(10) { // keep runtime reasonable while still stress-testing + val channel = createChannel() + val call = channel.newHelloCall() + val req = helloReq() + + val statusDeferred = CompletableDeferred() + val replyDeferred = CompletableDeferred() + val listener = object : ClientCall.Listener() { + override fun onMessage(message: HelloReply) { + replyDeferred.complete(message) + } + + override fun onClose(status: Status, trailers: GrpcTrailers) { + statusDeferred.complete(status) + } + } + + call.start(listener, GrpcTrailers()) + call.sendMessage(req) + call.halfClose() + call.request(1) + runBlocking { - grpc_init() - - val fullName = "/helloworld.Greeter/SayHello" - val descriptor = methodDescriptor( - fullMethodName = fullName, - requestCodec = HelloRequestInternal.CODEC, - responseCodec = HelloReplyInternal.CODEC, - type = MethodType.UNARY, - schemaDescriptor = Unit, - idempotent = true, - safe = true, - sampledToLocalTracing = true, - ) - val channel = NativeManagedChannel( - "localhost:50051", - GrpcInsecureCredentials(), - ) + withTimeout(10000) { + val status = statusDeferred.await() + val reply = replyDeferred.await() + assertEquals(StatusCode.OK, status.statusCode) + assertEquals("Hello world", reply.message) + } + } + shutdownAndWait(channel) + } - try { - val call = channel.newCall(descriptor, GrpcCallOptions()) - block(channel, call) + @Test + fun sendMessage_beforeStart_throws() { + val channel = createChannel() + val call = channel.newHelloCall() + val req = helloReq() + assertFailsWith { call.sendMessage(req) } + shutdownAndWait(channel) + } - } finally { - channel.shutdown() - grpc_shutdown() + @Test + fun request_beforeStart_throws() { + val channel = createChannel() + val call = channel.newHelloCall() + assertFailsWith { call.request(1) } + shutdownAndWait(channel) + } + + @Test + fun start_twice_throws() { + val channel = createChannel() + val call = channel.newHelloCall() + val statusDeferred = CompletableDeferred() + val listener = object : ClientCall.Listener() { + override fun onClose(status: Status, trailers: GrpcTrailers) { + statusDeferred.complete(status) } } + call.start(listener, GrpcTrailers()) + assertFailsWith { call.start(listener, GrpcTrailers()) } + // cancel to finish the call quickly + call.cancel("Double start test", null) + runBlocking { withTimeout(5000) { statusDeferred.await() } } + shutdownAndWait(channel) + } @Test - fun grpcClientTest() = runHelloWorld { channel, call -> - val req = HelloRequest { - name = "world" - timeout = 0u + fun send_afterHalfClose_throws() { + val channel = createChannel() + val call = channel.newHelloCall() + val req = helloReq() + val statusDeferred = CompletableDeferred() + val listener = object : ClientCall.Listener() { + override fun onClose(status: Status, trailers: GrpcTrailers) { + statusDeferred.complete(status) + } } + call.start(listener, GrpcTrailers()) + call.halfClose() + assertFailsWith { call.sendMessage(req) } + // Ensure call completes + call.cancel("cleanup", null) + runBlocking { withTimeout(5000) { statusDeferred.await() } } + shutdownAndWait(channel) + } - val sem = CompletableDeferred() - val helloReply = CompletableDeferred() + @Test + fun request_zero_throws() { + val channel = createChannel() + val call = channel.newHelloCall() + val statusDeferred = CompletableDeferred() + val listener = object : ClientCall.Listener() { + override fun onClose(status: Status, trailers: GrpcTrailers) { + statusDeferred.complete(status) + } + } + call.start(listener, GrpcTrailers()) + assertFailsWith { call.request(0) } + call.cancel("cleanup", null) + runBlocking { withTimeout(5000) { statusDeferred.await() } } + shutdownAndWait(channel) + } + @Test + fun cancel_afterStart_resultsInCancelledStatus() { + val channel = createChannel() + val call = channel.newHelloCall() + val statusDeferred = CompletableDeferred() val listener = object : ClientCall.Listener() { - override fun onMessage(message: HelloReply) { - helloReply.complete(message) + override fun onClose(status: Status, trailers: GrpcTrailers) { + statusDeferred.complete(status) + } + } + call.start(listener, GrpcTrailers()) + call.cancel("user cancel", null) + runBlocking { + withTimeout(10000) { + val status = statusDeferred.await() + assertEquals(StatusCode.CANCELLED, status.statusCode) } + } + shutdownAndWait(channel) + } + @Test + fun invalid_method_returnsNonOkStatus() { + val channel = createChannel() + val call = channel.newHelloCall("/helloworld.Greeter/NoSuchMethod") + val statusDeferred = CompletableDeferred() + val listener = object : ClientCall.Listener() { override fun onClose(status: Status, trailers: GrpcTrailers) { - sem.complete(status) + statusDeferred.complete(status) } } call.start(listener, GrpcTrailers()) - call.sendMessage(req) + call.sendMessage(helloReq()) call.halfClose() - delay(1) call.request(1) + runBlocking { + withTimeout(10000) { + val status = statusDeferred.await() + assertTrue(status.statusCode != StatusCode.OK) + } + } + shutdownAndWait(channel) + } - channel.shutdown() - - withTimeout(10000) { - val status = sem.await() - val helloReply = helloReply.await() - println("status: ${status.statusCode} (${status.getDescription()})") - println("helloReply: $helloReply") - assert(status.statusCode == StatusCode.OK) - assert(helloReply.message == "Hello world") + @Test + fun shutdownMidCall_resultsInUnavailableOrNonOk() { + val channel = createChannel() + val call = channel.newHelloCall() + val statusDeferred = CompletableDeferred() + val listener = object : ClientCall.Listener() { + override fun onClose(status: Status, trailers: GrpcTrailers) { + statusDeferred.complete(status) + } + } + call.start(listener, GrpcTrailers()) + call.sendMessage(helloReq()) + // Intentionally shut down before halfClose/request to simulate mid-flight shutdown + channel.shutdownNow() + // Even if we try to proceed, the CQ should signal shutdown + runBlocking { + withTimeout(10000) { + val status = statusDeferred.await() + assertTrue(status.statusCode != StatusCode.OK) + } } + shutdownAndWait(channel) } @Test - fun testNormalOften() { - for (i in 0..1000) { - grpcClientTest() + fun halfCloseBeforeSendingMessage_errorWithoutCrashing() { + val channel = createChannel() + val call = channel.newHelloCall() + val statusDeferred = CompletableDeferred() + val listener = object : ClientCall.Listener() { + override fun onClose(status: Status, trailers: GrpcTrailers) { + statusDeferred.complete(status) + } + } + assertFailsWith { + try { + call.start(listener, GrpcTrailers()) + call.halfClose() + call.sendMessage(helloReq()) + } finally { + shutdownAndWait(channel) + } } } + @Test + fun invokeStartAfterShutdown() { + val channel = createChannel() + val call = channel.newHelloCall() + val statusDeferred = CompletableDeferred() + val listener = object : ClientCall.Listener() { + override fun onClose(status: Status, trailers: GrpcTrailers) { + statusDeferred.complete(status) + } + } + + channel.shutdown() + call.start(listener, GrpcTrailers()) + call.halfClose() + call.sendMessage(helloReq()) + // Intentionally shut down before halfClose/request to simulate mid-flight shutdown + channel.shutdownNow() + } } \ No newline at end of file From b5527c83e49fe2e59c52110cf7a65901410854d6 Mon Sep 17 00:00:00 2001 From: Johannes Zottele Date: Fri, 15 Aug 2025 12:56:33 +0200 Subject: [PATCH 05/14] grpc-native: Add callback future Signed-off-by: Johannes Zottele --- .../rpc/grpc/internal/CallbackFuture.kt | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/CallbackFuture.kt diff --git a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/CallbackFuture.kt b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/CallbackFuture.kt new file mode 100644 index 000000000..9a1955ab6 --- /dev/null +++ b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/CallbackFuture.kt @@ -0,0 +1,29 @@ +/* + * 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.internal + +import kotlinx.atomicfu.atomic + +internal class CallbackFuture { + private val value = atomic(null) + private val callback = atomic<((T) -> Unit)?>(null) + + fun complete(result: T) { + if (value.compareAndSet(null, result)) { + callback.getAndSet(null)?.invoke(result) + } else { + error("Already completed") + } + } + + fun onComplete(cb: (T) -> Unit) { + val r = value.value + if (r != null) cb(r) + else if (!callback.compareAndSet(null, cb)) { + // Already someone registered → run immediately + value.value?.let(cb) + } + } +} \ No newline at end of file From 64afb878035ecf103b3a367b53182125ebc2fd65 Mon Sep 17 00:00:00 2001 From: Johannes Zottele Date: Fri, 15 Aug 2025 14:43:13 +0200 Subject: [PATCH 06/14] grpc-native: Refactor to use callbacks instead of coroutines Signed-off-by: Johannes Zottele --- .../kotlinx/rpc/grpc/ManagedChannel.native.kt | 31 ++-- .../rpc/grpc/internal/CallbackFuture.kt | 44 +++-- .../rpc/grpc/internal/CompletionQueue.kt | 41 +++-- .../rpc/grpc/internal/NativeClientCall.kt | 161 +++++++++--------- .../kotlinx/rpc/grpc/internal/CoreTest.kt | 62 ++++--- 5 files changed, 177 insertions(+), 162 deletions(-) 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 036898527..fa657c844 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 @@ -58,16 +58,15 @@ internal class NativeManagedChannel( target: String, // we must store them, otherwise the credentials are getting released credentials: GrpcCredentials, - dispatcher: CoroutineDispatcher = Dispatchers.Default, ) : ManagedChannel, ManagedChannelPlatform() { - // A reference to make sure the grpc_init() was called. (it is released after shutdown) + // a reference to make sure the grpc_init() was called. (it is released after shutdown) @Suppress("unused") private val rt = GrpcRuntime.acquire() - private val channelJob = SupervisorJob() - private val callJobSupervisor = SupervisorJob(channelJob) - private val channelScope = CoroutineScope(channelJob + dispatcher) + // job bundling all the call jobs created by this channel. + // this allows easy cancellation of ongoing calls. + private val callJobSupervisor = SupervisorJob() // the channel's completion queue, handling all request operations private val cq = CompletionQueue() @@ -111,23 +110,15 @@ internal class NativeManagedChannel( return } if (force) { + // TODO: replace jobs by custom pendingCallClass. callJobSupervisor.cancelChildren(CancellationException("Channel is shutting down")) } - // prevent any start() calls on already call jobs - callJobSupervisor.children.forEach { - (it as CompletableJob).complete() - } - callJobSupervisor.complete() - channelScope.launch { - withContext(NonCancellable) { - // wait for all child jobs to complete. - callJobSupervisor.join() - // wait for the completion queue to shut down. - cq.shutdown(force).await() - if (isTerminatedInternal.complete(Unit)) { - // release the grpc runtime, so it might call grpc_shutdown() - rt.close() - } + + // wait for the completion queue to shut down. + cq.shutdown(force).onComplete { + if (isTerminatedInternal.complete(Unit)) { + // release the grpc runtime, so it might call grpc_shutdown() + rt.close() } } } diff --git a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/CallbackFuture.kt b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/CallbackFuture.kt index 9a1955ab6..ae486a7f0 100644 --- a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/CallbackFuture.kt +++ b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/CallbackFuture.kt @@ -6,24 +6,44 @@ package kotlinx.rpc.grpc.internal import kotlinx.atomicfu.atomic +/** + * Thread safe future for callbacks. + */ internal class CallbackFuture { - private val value = atomic(null) - private val callback = atomic<((T) -> Unit)?>(null) + private sealed interface State { + data class Pending(val cbs: List<(T) -> Unit> = emptyList()) : State + data class Done(val value: T) : State + } + + private val state = atomic>(State.Pending()) fun complete(result: T) { - if (value.compareAndSet(null, result)) { - callback.getAndSet(null)?.invoke(result) - } else { - error("Already completed") + var toInvoke: List<(T) -> Unit> + while (true) { + when (val s = state.value) { + is State.Pending -> if (state.compareAndSet(s, State.Done(result))) { + toInvoke = s.cbs + break + } + + is State.Done -> error("Already completed") + } } + for (cb in toInvoke) cb(result) } - fun onComplete(cb: (T) -> Unit) { - val r = value.value - if (r != null) cb(r) - else if (!callback.compareAndSet(null, cb)) { - // Already someone registered → run immediately - value.value?.let(cb) + fun onComplete(callback: (T) -> Unit) { + while (true) { + when (val s = state.value) { + is State.Done -> { + callback(s.value); return + } + + is State.Pending -> { + val next = State.Pending(s.cbs + callback) // copy-on-write append + if (state.compareAndSet(s, next)) return + } + } } } } \ 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 64c7d6d19..f050bc4ae 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 @@ -6,22 +6,19 @@ package kotlinx.rpc.grpc.internal -import cnames.structs.grpc_call import kotlinx.atomicfu.atomic import kotlinx.atomicfu.locks.SynchronizedObject import kotlinx.atomicfu.locks.synchronized import kotlinx.cinterop.* -import kotlinx.coroutines.CompletableDeferred import libgrpcpp_c.* import platform.posix.memset import kotlin.experimental.ExperimentalNativeApi import kotlin.native.ref.createCleaner internal sealed interface BatchResult { - object Success : BatchResult - object ResultError : BatchResult object CQShutdown : BatchResult data class CallError(val error: grpc_call_error) : BatchResult + data class Called(val future: CallbackFuture) : BatchResult } /** @@ -45,7 +42,7 @@ internal class CompletionQueue { // internal as it must be accessible from the SHUTDOWN_CB, // but it shouldn't be used from outside this file. @Suppress("PropertyName") - internal val _shutdownDone = CompletableDeferred() + internal val _shutdownDone = CallbackFuture() // used for spinning lock. false means not used (available) private val batchStartGuard = SynchronizedObject() @@ -72,40 +69,41 @@ internal class CompletionQueue { require(kgrpc_iomgr_run_in_background()) { "The gRPC iomgr is not running background threads, required for callback based APIs." } } - // TODO: Remove this method - fun runBatch(call: NativeClientCall<*, *>, ops: CPointer, nOps: ULong) = - runBatch(call.raw, ops, nOps) - - fun runBatch(call: CPointer, ops: CPointer, nOps: ULong): CompletableDeferred { - val completion = CompletableDeferred() + fun runBatch(call: NativeClientCall<*, *>, ops: CPointer, nOps: ULong): BatchResult { + val completion = CallbackFuture() val tag = newCbTag(completion, OPS_COMPLETE_CB) var err = grpc_call_error.GRPC_CALL_ERROR + synchronized(batchStartGuard) { - // synchronizes access to grpc_call_start_batch + if (_state.value == State.SHUTTING_DOWN && ops.pointed.op == GRPC_OP_RECV_STATUS_ON_CLIENT) { + // if the queue is in the process of a SHUTDOWN, + // new call status receive batches will be rejected. + deleteCbTag(tag) + return BatchResult.CQShutdown + } + if (forceShutdown || _state.value == State.CLOSED) { // if the queue is either closed or in the process of a FORCE shutdown, // new batches will instantly fail. deleteCbTag(tag) - completion.complete(BatchResult.CQShutdown) - return completion + return BatchResult.CQShutdown } - err = grpc_call_start_batch(call, ops, nOps, tag, null) + err = grpc_call_start_batch(call.raw, ops, nOps, tag, null) } if (err != grpc_call_error.GRPC_CALL_OK) { // if the call was not successful, the callback will not be invoked. deleteCbTag(tag) - completion.complete(BatchResult.CallError(err)) - return completion + return BatchResult.CallError(err) } - return completion + return BatchResult.Called(completion) } // must not be canceled as it cleans resources and sets the state to CLOSED - fun shutdown(force: Boolean = false): CompletableDeferred { + fun shutdown(force: Boolean = false): CallbackFuture { if (force) { forceShutdown = true } @@ -130,10 +128,9 @@ internal class CompletionQueue { @CName("kq_ops_complete_cb") private fun opsCompleteCb(functor: CPointer?, ok: Int) { val tag = functor!!.reinterpret() - val cont = tag.pointed.user_data!!.asStableRef>().get() + val cont = tag.pointed.user_data!!.asStableRef>().get() deleteCbTag(tag) - if (ok != 0) cont.complete(BatchResult.Success) - else cont.complete(BatchResult.ResultError) + cont.complete(ok != 0) } @CName("kq_shutdown_cb") 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 d8d8c40d8..30fe8de90 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 @@ -9,10 +9,12 @@ package kotlinx.rpc.grpc.internal import cnames.structs.grpc_call import kotlinx.atomicfu.atomic import kotlinx.cinterop.* -import kotlinx.coroutines.* +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableJob import kotlinx.io.Buffer import kotlinx.rpc.grpc.GrpcTrailers import kotlinx.rpc.grpc.Status +import kotlinx.rpc.grpc.StatusCode import libgrpcpp_c.* import kotlin.experimental.ExperimentalNativeApi import kotlin.native.ref.createCleaner @@ -44,11 +46,6 @@ internal class NativeClientCall( } } - private val callRunJob = SupervisorJob() - private val callRunScope = CoroutineScope(callRunJob) - private val callBatchJob = SupervisorJob(callRunJob) - private val callBatchScope = CoroutineScope(callBatchJob) - private var listener: Listener? = null private var halfClosed = false private var cancelled = false @@ -59,76 +56,54 @@ internal class NativeClientCall( headers: GrpcTrailers, ) { check(listener == null) { "Already started" } - check(!closed.value) { "Already closed." } check(!cancelled) { "Already cancelled." } - // callJob is completed by the channel on shutdown to prevent start calls after shutdown - check(callJob.isActive) { "Call is cancelled or completed." } listener = responseListener // start receiving the status from the completion queue, // which is bound to the lifecycle of the call. - receiveStatus() - - - // sending and receiving initial metadata - val arena = Arena() - val opsNum = 2uL - val ops = arena.allocArray(opsNum.convert()) - - // send initial meta data to server - // TODO: initial metadata - ops[0].op = GRPC_OP_SEND_INITIAL_METADATA - ops[0].data.send_initial_metadata.count = 0u - - val meta = arena.alloc() - // TODO: make metadata array an object (for lifecycle management) - grpc_metadata_array_init(meta.ptr) - ops[1].op = GRPC_OP_RECV_INITIAL_METADATA - ops[1].data.recv_initial_metadata.recv_initial_metadata = meta.ptr + val success = initializeCallOnCQ() + if (!success) return - runBatch(ops, opsNum, cleanup = { - grpc_metadata_array_init(meta.ptr) - arena.clear() - }) { - // TODO: Send headers to listener - } + sendAndReceiveInitialMetadata() } private fun runBatch( ops: CPointer, nOps: ULong, cleanup: () -> Unit = {}, - onSuccess: suspend () -> Unit = {}, + onSuccess: () -> Unit = {}, ) { - val completion = cq.runBatch(this@NativeClientCall, ops, nOps) - callBatchScope.launch { - when (val result = completion.await()) { - BatchResult.Success -> onSuccess() - BatchResult.ResultError -> { - // do nothing, the client will receive the status from the completion queue - } - - BatchResult.CQShutdown -> { - cancelInternal(grpc_status_code.GRPC_STATUS_UNAVAILABLE, "Channel shutdown") + // we must not try to run a batch after the call is closed. + if (closed.value) return cleanup() + + when (val callResult = cq.runBatch(this@NativeClientCall, ops, nOps)) { + is BatchResult.Called -> { + callResult.future.onComplete { success -> + if (success) { + onSuccess() + } + // ignore failure, as it is reflected in the client status op + cleanup() } + } - is BatchResult.CallError -> { - cancelInternal( - grpc_status_code.GRPC_STATUS_INTERNAL, - "Batch could not be submitted: ${result.error}" - ) - } + BatchResult.CQShutdown -> { + cleanup() + cancelInternal(grpc_status_code.GRPC_STATUS_UNAVAILABLE, "Channel shutdown") } - }.invokeOnCompletion { - cleanup() - if (it != null) { - throw IllegalStateException("Unexpected exception during batch.", it) + + is BatchResult.CallError -> { + cleanup() + cancelInternal( + grpc_status_code.GRPC_STATUS_INTERNAL, + "Batch could not be submitted: ${callResult.error}" + ) } } } - private fun receiveStatus() { + private fun initializeCallOnCQ(): Boolean { checkNotNull(listener) { "Not yet started" } val arena = Arena() // this must not be canceled as it sets the call status. @@ -145,32 +120,60 @@ internal class NativeClientCall( data.recv_status_on_client.trailing_metadata = null } + when (val callResult = cq.runBatch(this@NativeClientCall, op.ptr, 1u)) { + is BatchResult.Called -> { + callResult.future.onComplete { + val status = Status(errorStr.value?.toKString(), statusCode.value.toKotlin(), null) + val trailers = GrpcTrailers() + arena.clear() + closeCall(status, trailers) + } + return true + } - val completion = cq.runBatch(this@NativeClientCall, op.ptr, 1u) - callRunScope.launch { - withContext(NonCancellable) { - // will never fail - completion.await() - callBatchJob.complete() - callBatchJob.join() - val status = Status(errorStr.value?.toKString(), statusCode.value.toKotlin(), null) - val trailers = GrpcTrailers() - println("Closing with status $status") - closeCall(status, trailers) + BatchResult.CQShutdown -> { + arena.clear() + closeCall(Status(StatusCode.UNAVAILABLE, "Channel shutdown"), GrpcTrailers()) + return false } - }.invokeOnCompletion { - arena.clear() - if (it != null) { - throw IllegalStateException("Unexpected exception during call.", it) + + is BatchResult.CallError -> { + arena.clear() + closeCall(Status(StatusCode.INTERNAL, "Failed to start call: ${callResult.error}"), GrpcTrailers()) + return false } } } + private fun sendAndReceiveInitialMetadata() { + // sending and receiving initial metadata + val arena = Arena() + val opsNum = 2uL + val ops = arena.allocArray(opsNum.convert()) + + // send initial meta data to server + // TODO: initial metadata + ops[0].op = GRPC_OP_SEND_INITIAL_METADATA + ops[0].data.send_initial_metadata.count = 0u + + val meta = arena.alloc() + // TODO: make metadata array an object (for lifecycle management) + grpc_metadata_array_init(meta.ptr) + ops[1].op = GRPC_OP_RECV_INITIAL_METADATA + ops[1].data.recv_initial_metadata.recv_initial_metadata = meta.ptr + + runBatch(ops, opsNum, cleanup = { + grpc_metadata_array_init(meta.ptr) + arena.clear() + }) { + // TODO: Send headers to listener + } + } + override fun request(numMessages: Int) { check(numMessages > 0) { "numMessages must be > 0" } val listener = checkNotNull(listener) { "Not yet started" } check(!cancelled) { "Already cancelled" } - check(!closed.value) { "Already closed." } fun once() { val arena = Arena() @@ -192,22 +195,20 @@ internal class NativeClientCall( override fun cancel(message: String?, cause: Throwable?) { cancelled = true - if (message != null) { - grpc_call_cancel(raw, null) - } else { - val message = if (cause != null) "$message: ${cause.message}" else message - cancelInternal(grpc_status_code.GRPC_STATUS_CANCELLED, message ?: "Call cancelled") - } + val message = if (cause != null) "$message: ${cause.message}" else message + cancelInternal(grpc_status_code.GRPC_STATUS_CANCELLED, message ?: "Call cancelled") } private fun cancelInternal(statusCode: grpc_status_code, message: String) { - grpc_call_cancel_with_status(raw, statusCode, message, null) + val cancelResult = grpc_call_cancel_with_status(raw, statusCode, message, null) + if (cancelResult != grpc_call_error.GRPC_CALL_OK) { + closeCall(Status(StatusCode.INTERNAL, "Failed to cancel call: $cancelResult"), GrpcTrailers()) + } } override fun halfClose() { check(!halfClosed) { "Already half closed." } check(!cancelled) { "Already cancelled." } - check(!closed.value) { "Already closed." } halfClosed = true val arena = Arena() @@ -224,7 +225,6 @@ internal class NativeClientCall( checkNotNull(listener) { "Not yet started" } check(!halfClosed) { "Already half closed." } check(!cancelled) { "Already cancelled." } - check(!closed.value) { "Already closed." } val arena = Arena() val inputStream = methodDescriptor.getRequestMarshaller().stream(message) @@ -245,7 +245,6 @@ internal class NativeClientCall( // only one close call must proceed if (closed.compareAndSet(expect = false, update = true)) { val listener = checkNotNull(listener) { "Not yet started" } - println("Closing with status ${status.statusCode}") callJob.complete() listener.onClose(status, trailers) } diff --git a/grpc/grpc-core/src/nativeTest/kotlin/kotlinx/rpc/grpc/internal/CoreTest.kt b/grpc/grpc-core/src/nativeTest/kotlin/kotlinx/rpc/grpc/internal/CoreTest.kt index 0aae600fb..b7f9caabf 100644 --- a/grpc/grpc-core/src/nativeTest/kotlin/kotlinx/rpc/grpc/internal/CoreTest.kt +++ b/grpc/grpc-core/src/nativeTest/kotlin/kotlinx/rpc/grpc/internal/CoreTest.kt @@ -12,6 +12,7 @@ import HelloRequestInternal import invoke import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout import kotlinx.rpc.grpc.* @@ -23,7 +24,6 @@ import kotlin.test.assertTrue class GrpcCoreTest { - // Helpers to reduce boilerplate across tests private fun descriptorFor(fullName: String = "/helloworld.Greeter/SayHello"): MethodDescriptor = methodDescriptor( fullMethodName = fullName, @@ -56,7 +56,7 @@ class GrpcCoreTest { } @Test - fun normalUnaryCall_ok() = repeat(10) { // keep runtime reasonable while still stress-testing + fun normalUnaryCall_ok() = repeat(1000) { val channel = createChannel() val call = channel.newHelloCall() val req = helloReq() @@ -206,8 +206,9 @@ class GrpcCoreTest { shutdownAndWait(channel) } + @Test - fun shutdownMidCall_resultsInUnavailableOrNonOk() { + fun halfCloseBeforeSendingMessage_errorWithoutCrashing() { val channel = createChannel() val call = channel.newHelloCall() val statusDeferred = CompletableDeferred() @@ -216,23 +217,19 @@ class GrpcCoreTest { statusDeferred.complete(status) } } - call.start(listener, GrpcTrailers()) - call.sendMessage(helloReq()) - // Intentionally shut down before halfClose/request to simulate mid-flight shutdown - channel.shutdownNow() - // Even if we try to proceed, the CQ should signal shutdown - runBlocking { - withTimeout(10000) { - val status = statusDeferred.await() - assertTrue(status.statusCode != StatusCode.OK) + assertFailsWith { + try { + call.start(listener, GrpcTrailers()) + call.halfClose() + call.sendMessage(helloReq()) + } finally { + shutdownAndWait(channel) } } - shutdownAndWait(channel) } - @Test - fun halfCloseBeforeSendingMessage_errorWithoutCrashing() { + fun invokeStartAfterShutdown() { val channel = createChannel() val call = channel.newHelloCall() val statusDeferred = CompletableDeferred() @@ -241,19 +238,23 @@ class GrpcCoreTest { statusDeferred.complete(status) } } - assertFailsWith { - try { - call.start(listener, GrpcTrailers()) - call.halfClose() - call.sendMessage(helloReq()) - } finally { - shutdownAndWait(channel) + + channel.shutdown() + call.start(listener, GrpcTrailers()) + call.sendMessage(helloReq()) + call.halfClose() + call.request(1) + + runBlocking { + withTimeout(10000) { + val status = statusDeferred.await() + assertEquals(StatusCode.UNAVAILABLE, status.statusCode) } } } @Test - fun invokeStartAfterShutdown() { + fun shutdownNowInMiddleOfCall() { val channel = createChannel() val call = channel.newHelloCall() val statusDeferred = CompletableDeferred() @@ -263,11 +264,18 @@ class GrpcCoreTest { } } - channel.shutdown() call.start(listener, GrpcTrailers()) + call.sendMessage(helloReq(1000u)) call.halfClose() - call.sendMessage(helloReq()) - // Intentionally shut down before halfClose/request to simulate mid-flight shutdown - channel.shutdownNow() + call.request(1) + + runBlocking { + delay(100) + channel.shutdownNow() + withTimeout(10000) { + val status = statusDeferred.await() + assertEquals(StatusCode.CANCELLED, status.statusCode) + } + } } } \ No newline at end of file From 112820904929167ad918222dbcdefaab86008475 Mon Sep 17 00:00:00 2001 From: Johannes Zottele Date: Mon, 18 Aug 2025 10:14:09 +0200 Subject: [PATCH 07/14] grpc-native: Fixes after rebase Signed-off-by: Johannes Zottele --- .../grpc/internal/MethodDescriptor.native.kt | 15 ++++-------- .../rpc/grpc/internal/NativeClientCall.kt | 5 ++-- .../kotlin/kotlinx/rpc/grpc/internal/utils.kt | 23 +++++++++++++++---- 3 files changed, 25 insertions(+), 18 deletions(-) diff --git a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/MethodDescriptor.native.kt b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/MethodDescriptor.native.kt index 847c7ed3a..ef95e782e 100644 --- a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/MethodDescriptor.native.kt +++ b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/MethodDescriptor.native.kt @@ -4,15 +4,10 @@ package kotlinx.rpc.grpc.internal -import kotlinx.io.Source import kotlinx.rpc.grpc.codec.MessageCodec import kotlinx.rpc.internal.utils.InternalRpcApi import kotlinx.rpc.protobuf.input.stream.InputStream -@InternalRpcApi -internal actual val MethodDescriptor<*, *>.type: MethodType - get() = TODO("Not yet implemented") - @InternalRpcApi public actual class MethodDescriptor internal constructor( private val fullMethodName: String, @@ -72,23 +67,21 @@ public actual fun methodDescriptor( ): MethodDescriptor { val requestMarshaller = object : MethodDescriptor.Marshaller { override fun stream(value: Request): InputStream { - val source = requestCodec.encode(value) - return object : InputStream(source) {} + return requestCodec.encode(value) } override fun parse(stream: InputStream): Request { - return requestCodec.decode(stream.source) + return requestCodec.decode(stream) } } val responseMarshaller = object : MethodDescriptor.Marshaller { override fun stream(value: Response): InputStream { - val source = responseCodec.encode(value) - return object : InputStream(source) {} + return responseCodec.encode(value) } override fun parse(stream: InputStream): Response { - return responseCodec.decode(stream.source) + return responseCodec.decode(stream) } } 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 30fe8de90..f88a37f52 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 @@ -11,10 +11,11 @@ import kotlinx.atomicfu.atomic import kotlinx.cinterop.* import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableJob -import kotlinx.io.Buffer import kotlinx.rpc.grpc.GrpcTrailers import kotlinx.rpc.grpc.Status import kotlinx.rpc.grpc.StatusCode +import kotlinx.rpc.protobuf.input.stream.asInputStream +import kotlinx.rpc.protobuf.input.stream.asSource import libgrpcpp_c.* import kotlin.experimental.ExperimentalNativeApi import kotlin.native.ref.createCleaner @@ -228,7 +229,7 @@ internal class NativeClientCall( val arena = Arena() val inputStream = methodDescriptor.getRequestMarshaller().stream(message) - val byteBuffer = (inputStream.source as Buffer).toGrpcByteBuffer() + val byteBuffer = inputStream.asSource().toGrpcByteBuffer() val op = arena.alloc { op = GRPC_OP_SEND_MESSAGE data.send_message.send_message = byteBuffer 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 008f53f2c..31d146ca6 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 @@ -7,14 +7,29 @@ package kotlinx.rpc.grpc.internal import kotlinx.cinterop.* -import kotlinx.io.Buffer -import kotlinx.io.Source -import kotlinx.io.UnsafeIoApi +import kotlinx.io.* import kotlinx.io.unsafe.UnsafeBufferOperations import kotlinx.rpc.grpc.StatusCode import libgrpcpp_c.* import platform.posix.memcpy +@OptIn(ExperimentalForeignApi::class, InternalIoApi::class, UnsafeIoApi::class) +internal fun Sink.writeFully(buffer: CPointer, offset: Long, length: Long) { + var consumed = 0L + while (consumed < length) { + UnsafeBufferOperations.writeToTail(this.buffer, 1) { array, start, endExclusive -> + val size = minOf(length - consumed, (endExclusive - start).toLong()) + + array.usePinned { + memcpy(it.addressOf(start), buffer + offset + consumed, size.convert()) + } + + consumed += size + size.toInt() + } + } +} + internal suspend fun withArena(block: suspend (Arena) -> Unit) = Arena().let { arena -> try { @@ -24,8 +39,6 @@ internal suspend fun withArena(block: suspend (Arena) -> Unit) = } } -internal fun Buffer.asInputStream(): InputStream = object : InputStream(this) {} - internal fun CPointer.toKotlin(destroy: Boolean = true): Buffer = memScoped { val reader = alloc() check(grpc_byte_buffer_reader_init(reader.ptr, this@toKotlin) == 1) From 21849158d2ad979da607bfa142f206b75cbe7a1d Mon Sep 17 00:00:00 2001 From: Johannes Zottele Date: Mon, 18 Aug 2025 16:38:16 +0200 Subject: [PATCH 08/14] grpc-native: Implement bridge to common Signed-off-by: Johannes Zottele --- cinterop-c/include/grpcpp_c.h | 3 - grpc/grpc-core/build.gradle.kts | 7 + .../kotlinx/rpc/grpc/internal/ClientCall.kt | 9 + .../rpc/grpc/internal/MethodDescriptor.kt | 6 + .../rpc/grpc/internal/suspendClientCalls.kt | 16 +- .../rpc/grpc/test/CancellationClientTest.kt | 88 ++++++++++ .../src/commonTest/proto/echo_grpc.proto | 25 +++ .../nativeInterop/cinterop/libgrpcpp_c.def | 7 +- .../kotlinx/rpc/grpc/ManagedChannel.native.kt | 154 +++--------------- .../rpc/grpc/StatusException.native.kt | 20 +-- .../rpc/grpc/internal/NativeClientCall.kt | 127 ++++++++++++--- .../rpc/grpc/internal/NativeManagedChannel.kt | 130 +++++++++++++++ .../kotlin/kotlinx/rpc/grpc/internal/utils.kt | 17 +- .../kotlinx/rpc/grpc/internal/CoreTest.kt | 55 ++++++- protobuf/protobuf-core/build.gradle.kts | 5 +- .../rpc/protoc/gen/core/codeRequestToModel.kt | 13 +- .../grpc/ModelToGrpcKotlinCommonGenerator.kt | 7 +- 17 files changed, 476 insertions(+), 213 deletions(-) create mode 100644 grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/CancellationClientTest.kt create mode 100644 grpc/grpc-core/src/commonTest/proto/echo_grpc.proto create mode 100644 grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeManagedChannel.kt diff --git a/cinterop-c/include/grpcpp_c.h b/cinterop-c/include/grpcpp_c.h index 92aa21283..48a5f6a05 100644 --- a/cinterop-c/include/grpcpp_c.h +++ b/cinterop-c/include/grpcpp_c.h @@ -77,9 +77,6 @@ typedef struct grpc_channel_credentials grpc_channel_credentials_t; bool kgrpc_iomgr_run_in_background(); -/////// UTILS /////// - - #ifdef __cplusplus } #endif diff --git a/grpc/grpc-core/build.gradle.kts b/grpc/grpc-core/build.gradle.kts index b6c4a6fc4..85432d86f 100644 --- a/grpc/grpc-core/build.gradle.kts +++ b/grpc/grpc-core/build.gradle.kts @@ -61,6 +61,13 @@ kotlin { implementation(libs.grpc.netty) } } + + nativeMain { + dependencies { + // required for status.proto + implementation(projects.protobuf.protobufCore) + } + } } configureCLibCInterop(project, ":grpcpp_c_static") { cinteropCLib -> 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 7984c0c3f..d90b2b1c2 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 @@ -8,6 +8,15 @@ import kotlinx.rpc.grpc.GrpcTrailers import kotlinx.rpc.grpc.Status import kotlinx.rpc.internal.utils.InternalRpcApi +/** + * This class represents a client-side call to a server. + * It provides the interface of the gRPC-Java ClientCall class; however, semantics are slightly different + * on JVM and Native platforms. + * + * Callback execution: + * - On JVM it is guaranteed that callbacks aren't executed concurrently. + * - On Native, it is only guaranteed that `onClose` is called after all other callbacks finished. + */ @InternalRpcApi public expect abstract class ClientCall { @InternalRpcApi diff --git a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/MethodDescriptor.kt b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/MethodDescriptor.kt index 189c0782b..fd961723d 100644 --- a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/MethodDescriptor.kt +++ b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/MethodDescriptor.kt @@ -37,6 +37,12 @@ public enum class MethodType { UNKNOWN, } +/** + * Creates a new [MethodDescriptor] instance. + * + * @param fullMethodName the full name of the method, consisting of the service name followed by a forward slash + * and the method name. It does not include a leading slash. + */ @InternalRpcApi public expect fun methodDescriptor( fullMethodName: String, 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 690512d89..1578c9277 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,23 +4,13 @@ package kotlinx.rpc.grpc.internal -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineName -import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.cancel +import kotlinx.coroutines.* 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.single -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlinx.rpc.grpc.GrpcTrailers -import kotlinx.rpc.grpc.Status -import kotlinx.rpc.grpc.StatusCode -import kotlinx.rpc.grpc.StatusException -import kotlinx.rpc.grpc.code +import kotlinx.rpc.grpc.* import kotlinx.rpc.internal.utils.InternalRpcApi // heavily inspired by @@ -249,7 +239,7 @@ internal fun Flow.singleOrStatusFlow( internal suspend fun Flow.singleOrStatus( expected: String, - descriptor: Any + descriptor: Any, ): T = singleOrStatusFlow(expected, descriptor).single() internal class Ready(private val isReallyReady: () -> Boolean) { diff --git a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/CancellationClientTest.kt b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/CancellationClientTest.kt new file mode 100644 index 000000000..a01d41996 --- /dev/null +++ b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/CancellationClientTest.kt @@ -0,0 +1,88 @@ +/* + * 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 + +import grpc.examples.echo.* +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.runTest +import kotlinx.rpc.grpc.ManagedChannelBuilder +import kotlinx.rpc.grpc.buildChannel +import kotlinx.rpc.grpc.internal.* +import kotlin.test.Test +import kotlin.test.assertEquals + +class CancellationClientTest { + + @Test + fun unaryEchoTest() = runTest( + methodName = "UnaryEcho", + type = MethodType.UNARY, + ) { channel, descriptor -> + val response = unaryRpc(channel, descriptor, EchoRequest { message = "Eccchhooo" }) + assertEquals("Eccchhooo", response.message) + } + + @Test + fun serverStreamingEchoTest() = runTest( + methodName = "ServerStreamingEcho", + type = MethodType.SERVER_STREAMING, + ) { channel, descriptor -> + val response = serverStreamingRpc(channel, descriptor, EchoRequest { message = "Eccchhooo" }) + var i = 0 + response.collect { + println("Received: ${i++}") + assertEquals("Eccchhooo", it.message) + } + } + + @Test + fun clientStreamingTest() = runTest( + methodName = "ServerStreamingEcho", + type = MethodType.CLIENT_STREAMING, + ) { channel, descriptor -> + val response = clientStreamingRpc(channel, descriptor, flow { + repeat(5) { + delay(100) + println("Sending: ${it + 1}") + emit(EchoRequest { message = "Eccchhooo" }) + } + }) + val expected = "Eccchhooo,Eccchhooo,Eccchhooo,Eccchhooo,Eccchhooo" + assertEquals(expected, response.message) + } + + fun runTest( + methodName: String, + type: MethodType, + block: suspend (GrpcChannel, MethodDescriptor) -> Unit, + ) = runTest { + val channel = ManagedChannelBuilder("localhost:50051") + .usePlaintext() + .buildChannel() + + val methodDescriptor = methodDescriptor( + fullMethodName = "grpc.examples.echo.Echo/$methodName", + requestCodec = EchoRequestInternal.CODEC, + responseCodec = EchoResponseInternal.CODEC, + type = type, + schemaDescriptor = Unit, + idempotent = true, + safe = true, + sampledToLocalTracing = true, + ) + + try { + block(channel.platformApi, methodDescriptor) + } finally { + channel.shutdown() + channel.awaitTermination() + } + + + } + + +} \ No newline at end of file diff --git a/grpc/grpc-core/src/commonTest/proto/echo_grpc.proto b/grpc/grpc-core/src/commonTest/proto/echo_grpc.proto new file mode 100644 index 000000000..4c960d90b --- /dev/null +++ b/grpc/grpc-core/src/commonTest/proto/echo_grpc.proto @@ -0,0 +1,25 @@ +syntax = "proto3"; + +package grpc.examples.echo; + +// EchoRequest is the request for echo. +message EchoRequest { + string message = 1; +} + +// EchoResponse is the response for echo. +message EchoResponse { + string message = 1; +} + +// Echo is the echo service. +service Echo { + // UnaryEcho is unary echo. + rpc UnaryEcho(EchoRequest) returns (EchoResponse) {} + // ServerStreamingEcho is server side streaming. + rpc ServerStreamingEcho(EchoRequest) returns (stream EchoResponse) {} + // ClientStreamingEcho is client side streaming. + rpc ClientStreamingEcho(stream EchoRequest) returns (EchoResponse) {} + // BidirectionalStreamingEcho is bidi streaming. + rpc BidirectionalStreamingEcho(stream EchoRequest) returns (stream EchoResponse) {} +} \ No newline at end of file diff --git a/grpc/grpc-core/src/nativeInterop/cinterop/libgrpcpp_c.def b/grpc/grpc-core/src/nativeInterop/cinterop/libgrpcpp_c.def index 7fb8d1482..84ea16026 100644 --- a/grpc/grpc-core/src/nativeInterop/cinterop/libgrpcpp_c.def +++ b/grpc/grpc-core/src/nativeInterop/cinterop/libgrpcpp_c.def @@ -1,6 +1,9 @@ -headers = grpcpp_c.h grpc/grpc.h grpc/credentials.h grpc/byte_buffer_reader.h +headers = grpcpp_c.h grpc/grpc.h grpc/credentials.h grpc/byte_buffer_reader.h \ + grpc/support/alloc.h + headerFilter= grpcpp_c.h grpc/slice.h grpc/byte_buffer.h grpc/grpc.h \ - grpc/impl/grpc_types.h grpc/credentials.h grpc/support/time.h grpc/byte_buffer_reader.h + grpc/impl/grpc_types.h grpc/credentials.h grpc/support/time.h grpc/byte_buffer_reader.h \ + grpc/support/alloc.h noStringConversion = grpc_slice_from_copied_buffer my_grpc_slice_from_copied_buffer strictEnums = grpc_status_code grpc_connectivity_state grpc_call_error 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 fa657c844..5c6c40389 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 @@ -3,19 +3,13 @@ */ @file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") -@file:OptIn(ExperimentalForeignApi::class, ExperimentalNativeApi::class) package kotlinx.rpc.grpc -import cnames.structs.grpc_channel -import kotlinx.cinterop.CPointer -import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.coroutines.* -import kotlinx.rpc.grpc.internal.* -import libgrpcpp_c.* -import kotlin.experimental.ExperimentalNativeApi -import kotlin.native.ref.createCleaner -import kotlin.time.Duration +import kotlinx.rpc.grpc.internal.GrpcChannel +import kotlinx.rpc.grpc.internal.GrpcCredentials +import kotlinx.rpc.grpc.internal.GrpcInsecureCredentials +import kotlinx.rpc.grpc.internal.NativeManagedChannel /** * Same as [ManagedChannel], but is platform-exposed. @@ -26,138 +20,42 @@ public actual abstract class ManagedChannelPlatform : GrpcChannel() * Builder class for [ManagedChannel]. */ public actual abstract class ManagedChannelBuilder> { - public actual fun usePlaintext(): T { - TODO("Not yet implemented") + public actual open fun usePlaintext(): T { + error("Builder does not override usePlaintext()") } } -internal actual fun ManagedChannelBuilder<*>.buildChannel(): ManagedChannel { - TODO("Not yet implemented") -// return NativeManagedChannel( -// target = "localhost:50051", -// credentials = GrpcCredentials( -// grpc_insecure_credentials_create() -// ?: error("Failed to create credentials") -// ), -// -// ) -} - -internal actual fun ManagedChannelBuilder(hostname: String, port: Int): ManagedChannelBuilder<*> { - error("Native target is not supported in gRPC") -} - -internal actual fun ManagedChannelBuilder(target: String): ManagedChannelBuilder<*> { - error("Native target is not supported in gRPC") -} - - -private const val GRPC_PROPAGATE_DEFAULTS = 0x0000FFFFu - -internal class NativeManagedChannel( - target: String, - // we must store them, otherwise the credentials are getting released - credentials: GrpcCredentials, -) : ManagedChannel, ManagedChannelPlatform() { - - // a reference to make sure the grpc_init() was called. (it is released after shutdown) - @Suppress("unused") - private val rt = GrpcRuntime.acquire() - - // job bundling all the call jobs created by this channel. - // this allows easy cancellation of ongoing calls. - private val callJobSupervisor = SupervisorJob() - - // the channel's completion queue, handling all request operations - private val cq = CompletionQueue() - - internal val raw: CPointer = grpc_channel_create(target, credentials.raw, null) - ?: error("Failed to create channel") - - @Suppress("unused") - private val rawCleaner = createCleaner(raw) { - grpc_channel_destroy(it) - } - - override val platformApi: ManagedChannelPlatform = this - - private var isShutdownInternal: Boolean = false - override val isShutdown: Boolean = isShutdownInternal - private var isTerminatedInternal = CompletableDeferred() - override val isTerminated: Boolean - get() = isTerminatedInternal.isCompleted +internal class NativeManagedChannelBuilder( + private val target: String, +) : ManagedChannelBuilder() { + private var credentials: GrpcCredentials? = null - override suspend fun awaitTermination(duration: Duration): Boolean { - withTimeoutOrNull(duration) { - isTerminatedInternal.await() - } ?: return false - return true - } - - override fun shutdown(): ManagedChannel { - shutdownInternal(false) - return this - } - - override fun shutdownNow(): ManagedChannel { - shutdownInternal(true) + override fun usePlaintext(): NativeManagedChannelBuilder { + check(credentials == null) { "Credentials already set" } + credentials = GrpcInsecureCredentials() return this } - private fun shutdownInternal(force: Boolean) { - isShutdownInternal = true - if (isTerminatedInternal.isCompleted) { - return - } - if (force) { - // TODO: replace jobs by custom pendingCallClass. - callJobSupervisor.cancelChildren(CancellationException("Channel is shutting down")) - } - - // wait for the completion queue to shut down. - cq.shutdown(force).onComplete { - if (isTerminatedInternal.complete(Unit)) { - // release the grpc runtime, so it might call grpc_shutdown() - rt.close() - } - } - } - - override fun newCall( - methodDescriptor: MethodDescriptor, - callOptions: GrpcCallOptions, - ): ClientCall { - check(!isShutdown) { "Channel is shutdown" } - - val callJob = Job(callJobSupervisor) - - val methodNameSlice = methodDescriptor.getFullMethodName().toGrpcSlice() - val rawCall = grpc_channel_create_call( - channel = raw, parent_call = null, propagation_mask = GRPC_PROPAGATE_DEFAULTS, completion_queue = cq.raw, - method = methodNameSlice, host = null, deadline = gpr_inf_future(GPR_CLOCK_REALTIME), reserved = null - ) ?: error("Failed to create call") - - return NativeClientCall( - cq, rawCall, methodDescriptor, callJob + fun buildChannel(): NativeManagedChannel { + return NativeManagedChannel( + target, + credentials = credentials ?: error("No credentials set"), ) } - override fun authority(): String { - TODO("Not yet implemented") - } - } +internal actual fun ManagedChannelBuilder<*>.buildChannel(): ManagedChannel { + check(this is NativeManagedChannelBuilder) { "Wrong builder type, expected NativeManagedChannelBuilder" } + return buildChannel() +} -internal sealed class GrpcCredentials( - internal val raw: CPointer, -) { - val rawCleaner = createCleaner(raw) { - grpc_channel_credentials_release(it) - } +internal actual fun ManagedChannelBuilder(hostname: String, port: Int): ManagedChannelBuilder<*> { + return NativeManagedChannelBuilder(target = "$hostname:$port") } -internal class GrpcInsecureCredentials() : - GrpcCredentials(grpc_insecure_credentials_create() ?: error("Failed to create credentials")) +internal actual fun ManagedChannelBuilder(target: String): ManagedChannelBuilder<*> { + return NativeManagedChannelBuilder(target) +} 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 ab6ba2002..e319178e4 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 @@ -8,12 +8,12 @@ public actual class StatusException : Exception { private val status: Status private val trailers: GrpcTrailers? - public actual constructor(status: Status) : super(status.getDescription()) { - this.status = status - this.trailers = null - } + public actual constructor(status: Status) : this(status, null) - public actual constructor(status: Status, trailers: GrpcTrailers?) : super(status.getDescription()) { + public actual constructor(status: Status, trailers: GrpcTrailers?) : super( + "${status.statusCode}: ${status.getDescription()}", + status.getCause() + ) { this.status = status this.trailers = trailers } @@ -27,12 +27,12 @@ public actual class StatusRuntimeException : RuntimeException { private val status: Status private val trailers: GrpcTrailers? - public actual constructor(status: Status) : super(status.getDescription()) { - this.status = status - this.trailers = null - } + public actual constructor(status: Status) : this(status, null) - public actual constructor(status: Status, trailers: GrpcTrailers?) : super(status.getDescription()) { + public actual constructor(status: Status, trailers: GrpcTrailers?) : super( + "${status.statusCode}: ${status.getDescription()}", + status.getCause() + ) { this.status = status this.trailers = 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 f88a37f52..556bb92a5 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 @@ -52,6 +52,68 @@ internal class NativeClientCall( private var cancelled = false private var closed = atomic(false) + // tracks how many operations are in flight (not yet completed by the listener). + // if 0, there are no more operations (except for the RECV_STATUS_ON_CLIENT op). + // in this case, we can safely call onClose on the listener. + // we need this mechanism to ensure that onClose is not called while any other callback is still running + // on the listener. + private val inFlight = atomic(0) + + // 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) + + /** + * Increments the [inFlight] counter by one. + * This should be called before starting a batch. + */ + private fun beginOp() { + inFlight.incrementAndGet() + } + + /** + * Decrements the [inFlight] counter by one. + * This should be called after a batch has finished (in case of success AND error) + * AND the corresponding listener callback returned. + * + * If the counter reaches 0, no more listener callbacks are executed, and the call can be closed by + * calling [tryDeliverClose]. + */ + private fun endOp() { + if (inFlight.decrementAndGet() == 0) { + tryDeliverClose() + } + } + + /** + * Tries to close the call by invoking the listener's onClose callback. + * + * - If the call is already closed, this does nothing. + * - If the RECV_STATUS_ON_CLIENT batch is still in progress, this does nothing. + * - If the [inFlight] counter is not 0, this does nothing. + * - Otherwise, the listener's onClose callback is invoked and the call is closed. + */ + private fun tryDeliverClose() { + val s = closeInfo.value ?: return + if (inFlight.value == 0 && closed.compareAndSet(expect = false, update = true)) { + val lst = checkNotNull(listener) { "Not yet started" } + // allows the managed channel to join for the call to finish. + callJob.complete() + lst.onClose(s.first, s.second) + } + } + + /** + * Sets the [closeInfo] and calls [tryDeliverClose]. + * This is called as soon as the RECV_STATUS_ON_CLIENT batch is finished. + */ + private fun markClosePending(status: Status, trailers: GrpcTrailers) { + if (closeInfo.compareAndSet(null, Pair(status, trailers))) { + tryDeliverClose() + } + } + + override fun start( responseListener: Listener, headers: GrpcTrailers, @@ -78,24 +140,33 @@ internal class NativeClientCall( // we must not try to run a batch after the call is closed. if (closed.value) return cleanup() + // pre-book the batch, so onClose cannot be called before the batch finished. + beginOp() + when (val callResult = cq.runBatch(this@NativeClientCall, ops, nOps)) { is BatchResult.Called -> { callResult.future.onComplete { success -> - if (success) { - onSuccess() + try { + if (success) { + onSuccess() + } + } finally { + // ignore failure, as it is reflected in the client status op + cleanup() + endOp() } - // ignore failure, as it is reflected in the client status op - cleanup() } } BatchResult.CQShutdown -> { cleanup() + endOp() cancelInternal(grpc_status_code.GRPC_STATUS_UNAVAILABLE, "Channel shutdown") } is BatchResult.CallError -> { cleanup() + endOp() cancelInternal( grpc_status_code.GRPC_STATUS_INTERNAL, "Batch could not be submitted: ${callResult.error}" @@ -104,6 +175,7 @@ internal class NativeClientCall( } } + @OptIn(ExperimentalStdlibApi::class) private fun initializeCallOnCQ(): Boolean { checkNotNull(listener) { "Not yet started" } val arena = Arena() @@ -124,23 +196,33 @@ internal class NativeClientCall( when (val callResult = cq.runBatch(this@NativeClientCall, op.ptr, 1u)) { is BatchResult.Called -> { callResult.future.onComplete { - val status = Status(errorStr.value?.toKString(), statusCode.value.toKotlin(), null) + val details = statusDetails.toByteArray().toKString() + val status = Status(statusCode.value.toKotlin(), details, null) val trailers = GrpcTrailers() + + // cleanup + grpc_slice_unref(statusDetails.readValue()) + if (errorStr.value != null) gpr_free(errorStr.value) arena.clear() - closeCall(status, trailers) + + // set close info and try to close the call. + markClosePending(status, trailers) } return true } BatchResult.CQShutdown -> { arena.clear() - closeCall(Status(StatusCode.UNAVAILABLE, "Channel shutdown"), GrpcTrailers()) + markClosePending(Status(StatusCode.UNAVAILABLE, "Channel shutdown"), GrpcTrailers()) return false } is BatchResult.CallError -> { arena.clear() - closeCall(Status(StatusCode.INTERNAL, "Failed to start call: ${callResult.error}"), GrpcTrailers()) + markClosePending( + Status(StatusCode.INTERNAL, "Failed to start call: ${callResult.error}"), + GrpcTrailers() + ) return false } } @@ -164,7 +246,7 @@ internal class NativeClientCall( ops[1].data.recv_initial_metadata.recv_initial_metadata = meta.ptr runBatch(ops, opsNum, cleanup = { - grpc_metadata_array_init(meta.ptr) + grpc_metadata_array_destroy(meta.ptr) arena.clear() }) { // TODO: Send headers to listener @@ -176,22 +258,28 @@ internal class NativeClientCall( val listener = checkNotNull(listener) { "Not yet started" } check(!cancelled) { "Already cancelled" } - fun once() { + var remainingMessages = numMessages + fun post() { + if (remainingMessages-- <= 0) return + val arena = Arena() val recvPtr = arena.alloc>() val op = arena.alloc { op = GRPC_OP_RECV_MESSAGE data.recv_message.recv_message = recvPtr.ptr } - runBatch(op.ptr, 1u, cleanup = { arena.clear() }) { + runBatch(op.ptr, 1u, cleanup = { + if (recvPtr.value != null) grpc_byte_buffer_destroy(recvPtr.value) + arena.clear() + }) { val buf = recvPtr.value ?: return@runBatch // EOS val msg = methodDescriptor.getResponseMarshaller() .parse(buf.toKotlin().asInputStream()) listener.onMessage(msg) - once() // post next only now + post() // post next only now } } - once() + post() } override fun cancel(message: String?, cause: Throwable?) { @@ -203,7 +291,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) { - closeCall(Status(StatusCode.INTERNAL, "Failed to cancel call: $cancelResult"), GrpcTrailers()) + markClosePending(Status(StatusCode.INTERNAL, "Failed to cancel call: $cancelResult"), GrpcTrailers()) } } @@ -213,7 +301,7 @@ internal class NativeClientCall( halfClosed = true val arena = Arena() - val op = arena.alloc() { + val op = arena.alloc { op = GRPC_OP_SEND_CLOSE_FROM_CLIENT } @@ -241,15 +329,6 @@ internal class NativeClientCall( // Nothing to do here } } - - private fun closeCall(status: Status, trailers: GrpcTrailers) { - // only one close call must proceed - if (closed.compareAndSet(expect = false, update = true)) { - val listener = checkNotNull(listener) { "Not yet started" } - callJob.complete() - listener.onClose(status, trailers) - } - } } diff --git a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeManagedChannel.kt b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeManagedChannel.kt new file mode 100644 index 000000000..28f767356 --- /dev/null +++ b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeManagedChannel.kt @@ -0,0 +1,130 @@ +/* + * Copyright 2023-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +@file:OptIn(ExperimentalForeignApi::class, ExperimentalNativeApi::class) + +package kotlinx.rpc.grpc.internal + +import cnames.structs.grpc_channel +import kotlinx.cinterop.CPointer +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.coroutines.* +import kotlinx.rpc.grpc.ManagedChannel +import kotlinx.rpc.grpc.ManagedChannelPlatform +import libgrpcpp_c.* +import kotlin.coroutines.cancellation.CancellationException +import kotlin.experimental.ExperimentalNativeApi +import kotlin.native.ref.createCleaner +import kotlin.time.Duration + +internal sealed class GrpcCredentials( + internal val raw: CPointer, +) { + val rawCleaner = createCleaner(raw) { + grpc_channel_credentials_release(it) + } +} + +internal class GrpcInsecureCredentials() : + GrpcCredentials(grpc_insecure_credentials_create() ?: error("Failed to create credentials")) + + +private const val GRPC_PROPAGATE_DEFAULTS = 0x0000FFFFu + +internal class NativeManagedChannel( + target: String, + // we must store them, otherwise the credentials are getting released + credentials: GrpcCredentials, +) : ManagedChannel, ManagedChannelPlatform() { + + // a reference to make sure the grpc_init() was called. (it is released after shutdown) + @Suppress("unused") + private val rt = GrpcRuntime.acquire() + + // job bundling all the call jobs created by this channel. + // this allows easy cancellation of ongoing calls. + private val callJobSupervisor = SupervisorJob() + + // the channel's completion queue, handling all request operations + private val cq = CompletionQueue() + + internal val raw: CPointer = grpc_channel_create(target, credentials.raw, null) + ?: error("Failed to create channel") + + @Suppress("unused") + private val rawCleaner = createCleaner(raw) { + grpc_channel_destroy(it) + } + + override val platformApi: ManagedChannelPlatform = this + + private var isShutdownInternal: Boolean = false + override val isShutdown: Boolean = isShutdownInternal + private var isTerminatedInternal = CompletableDeferred() + override val isTerminated: Boolean + get() = isTerminatedInternal.isCompleted + + override suspend fun awaitTermination(duration: Duration): Boolean { + withTimeoutOrNull(duration) { + isTerminatedInternal.await() + } ?: return false + return true + } + + override fun shutdown(): ManagedChannel { + shutdownInternal(false) + return this + } + + override fun shutdownNow(): ManagedChannel { + shutdownInternal(true) + return this + } + + private fun shutdownInternal(force: Boolean) { + isShutdownInternal = true + if (isTerminatedInternal.isCompleted) { + return + } + if (force) { + // TODO: replace jobs by custom pendingCallClass. + callJobSupervisor.cancelChildren(CancellationException("Channel is shutting down")) + } + + // wait for the completion queue to shut down. + cq.shutdown(force).onComplete { + if (isTerminatedInternal.complete(Unit)) { + // release the grpc runtime, so it might call grpc_shutdown() + rt.close() + } + } + } + + override fun newCall( + methodDescriptor: MethodDescriptor, + callOptions: GrpcCallOptions, + ): ClientCall { + check(!isShutdown) { "Channel is shutdown" } + + val callJob = Job(callJobSupervisor) + + val methodFullName = methodDescriptor.getFullMethodName() + // to construct a valid HTTP/2 path, we must prepend the name with a slash. + // the user does not do this to align it with the java implementation. + val methodNameSlice = "/$methodFullName".toGrpcSlice() + val rawCall = grpc_channel_create_call( + channel = raw, parent_call = null, propagation_mask = GRPC_PROPAGATE_DEFAULTS, completion_queue = cq.raw, + method = methodNameSlice, host = null, deadline = gpr_inf_future(GPR_CLOCK_REALTIME), reserved = null + ) ?: error("Failed to create call") + + return NativeClientCall( + cq, rawCall, methodDescriptor, callJob + ) + } + + override fun authority(): String { + TODO("Not yet implemented") + } + +} 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 31d146ca6..d661fe0de 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 @@ -39,7 +39,17 @@ internal suspend fun withArena(block: suspend (Arena) -> Unit) = } } -internal fun CPointer.toKotlin(destroy: Boolean = true): Buffer = memScoped { +internal fun grpc_slice.toByteArray(): ByteArray = memScoped { + val out = ByteArray(len().toInt()) + if (out.isEmpty()) return out + + out.usePinned { + memcpy(it.addressOf(0), startPtr(), len().convert()) + } + return out +} + +internal fun CPointer.toKotlin(): Buffer = memScoped { val reader = alloc() check(grpc_byte_buffer_reader_init(reader.ptr, this@toKotlin) == 1) { "Failed to initialized byte buffer." } @@ -55,14 +65,9 @@ internal fun CPointer.toKotlin(destroy: Boolean = true): Buffe } grpc_byte_buffer_reader_destroy(reader.ptr) - if (destroy) { - grpc_byte_buffer_destroy(this@toKotlin) - } - return out } - internal fun Source.toGrpcByteBuffer(): CPointer { if (this is Buffer) return toGrpcByteBuffer() diff --git a/grpc/grpc-core/src/nativeTest/kotlin/kotlinx/rpc/grpc/internal/CoreTest.kt b/grpc/grpc-core/src/nativeTest/kotlin/kotlinx/rpc/grpc/internal/CoreTest.kt index b7f9caabf..4db3e63aa 100644 --- a/grpc/grpc-core/src/nativeTest/kotlin/kotlinx/rpc/grpc/internal/CoreTest.kt +++ b/grpc/grpc-core/src/nativeTest/kotlin/kotlinx/rpc/grpc/internal/CoreTest.kt @@ -9,6 +9,10 @@ import HelloReply import HelloReplyInternal import HelloRequest import HelloRequestInternal +import grpc.examples.echo.EchoRequest +import grpc.examples.echo.EchoRequestInternal +import grpc.examples.echo.EchoResponseInternal +import grpc.examples.echo.invoke import invoke import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.coroutines.CompletableDeferred @@ -24,7 +28,7 @@ import kotlin.test.assertTrue class GrpcCoreTest { - private fun descriptorFor(fullName: String = "/helloworld.Greeter/SayHello"): MethodDescriptor = + private fun descriptorFor(fullName: String = "helloworld.Greeter/SayHello"): MethodDescriptor = methodDescriptor( fullMethodName = fullName, requestCodec = HelloRequestInternal.CODEC, @@ -36,21 +40,20 @@ class GrpcCoreTest { sampledToLocalTracing = true, ) - private fun NativeManagedChannel.newHelloCall(fullName: String = "/helloworld.Greeter/SayHello"): ClientCall = - newCall(descriptorFor(fullName), GrpcCallOptions()) + private fun ManagedChannel.newHelloCall(fullName: String = "helloworld.Greeter/SayHello"): ClientCall = + platformApi.newCall(descriptorFor(fullName), GrpcCallOptions()) + + private fun createChannel(): ManagedChannel = ManagedChannelBuilder("localhost:50051") + .usePlaintext() + .buildChannel() - private fun createChannel(): NativeManagedChannel = - NativeManagedChannel( - "localhost:50051", - GrpcInsecureCredentials(), - ) private fun helloReq(timeout: UInt = 0u): HelloRequest = HelloRequest { name = "world" this.timeout = timeout } - private fun shutdownAndWait(channel: NativeManagedChannel) { + private fun shutdownAndWait(channel: ManagedChannel) { channel.shutdown() runBlocking { channel.awaitTermination() } } @@ -265,6 +268,7 @@ class GrpcCoreTest { } call.start(listener, GrpcTrailers()) + // set timeout on the server to 1000 ms, to simulate a long-running call call.sendMessage(helloReq(1000u)) call.halfClose() call.request(1) @@ -278,4 +282,37 @@ class GrpcCoreTest { } } } + + @Test + fun unaryCallTest() = runBlocking { + val ch = createChannel() + val desc = descriptorFor() + val req = helloReq() + repeat(1000) { + val res = unaryRpc(ch.platformApi, desc, req) + assertEquals("Hello world", res.message) + } + } + + + private fun echoDescriptor(methodName: String, type: MethodType) = + methodDescriptor( + fullMethodName = "grpc.examples.echo.Echo/$methodName", + requestCodec = EchoRequestInternal.CODEC, + responseCodec = EchoResponseInternal.CODEC, + type = type, + schemaDescriptor = Unit, + idempotent = true, + safe = true, + sampledToLocalTracing = true, + ) + + @Test + fun unaryEchoTest() = runBlocking { + val ch = createChannel() + val desc = echoDescriptor("UnaryEcho", MethodType.UNARY) + val req = EchoRequest { message = "Echoooo" } + unaryRpc(ch.platformApi, desc, req) + return@runBlocking + } } \ No newline at end of file diff --git a/protobuf/protobuf-core/build.gradle.kts b/protobuf/protobuf-core/build.gradle.kts index 52157cbc7..203a40632 100644 --- a/protobuf/protobuf-core/build.gradle.kts +++ b/protobuf/protobuf-core/build.gradle.kts @@ -4,10 +4,10 @@ @file:OptIn(InternalRpcApi::class) -import org.jetbrains.kotlin.gradle.dsl.KotlinVersion import kotlinx.rpc.buf.tasks.BufGenerateTask import kotlinx.rpc.internal.InternalRpcApi import kotlinx.rpc.internal.configureLocalProtocGenDevelopmentDependency +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion import util.configureCLibCInterop plugins { @@ -28,6 +28,7 @@ kotlin { api(projects.utils) api(projects.protobuf.protobufInputStream) api(projects.grpc.grpcCodec) + implementation("com.google.api.grpc:proto-google-common-protos:2.60.0") api(libs.kotlinx.io.core) } @@ -55,7 +56,7 @@ kotlin { configureCLibCInterop(project, ":protowire_static") { cinteropCLib -> @Suppress("unused") - val libprotowire by creating { + val libprotowire by creating { includeDirs( cinteropCLib.resolve("include") ) diff --git a/protoc-gen/common/src/main/kotlin/kotlinx/rpc/protoc/gen/core/codeRequestToModel.kt b/protoc-gen/common/src/main/kotlin/kotlinx/rpc/protoc/gen/core/codeRequestToModel.kt index adbcaca1c..5aea4bcaa 100644 --- a/protoc-gen/common/src/main/kotlin/kotlinx/rpc/protoc/gen/core/codeRequestToModel.kt +++ b/protoc-gen/common/src/main/kotlin/kotlinx/rpc/protoc/gen/core/codeRequestToModel.kt @@ -7,16 +7,7 @@ package kotlinx.rpc.protoc.gen.core import com.google.protobuf.DescriptorProtos import com.google.protobuf.Descriptors import com.google.protobuf.compiler.PluginProtos.CodeGeneratorRequest -import kotlinx.rpc.protoc.gen.core.model.EnumDeclaration -import kotlinx.rpc.protoc.gen.core.model.FieldDeclaration -import kotlinx.rpc.protoc.gen.core.model.FieldType -import kotlinx.rpc.protoc.gen.core.model.FileDeclaration -import kotlinx.rpc.protoc.gen.core.model.FqName -import kotlinx.rpc.protoc.gen.core.model.MessageDeclaration -import kotlinx.rpc.protoc.gen.core.model.MethodDeclaration -import kotlinx.rpc.protoc.gen.core.model.Model -import kotlinx.rpc.protoc.gen.core.model.OneOfDeclaration -import kotlinx.rpc.protoc.gen.core.model.ServiceDeclaration +import kotlinx.rpc.protoc.gen.core.model.* private val nameCache = mutableMapOf() private val modelCache = mutableMapOf() @@ -51,7 +42,7 @@ fun CodeGeneratorRequest.toModel(): Model { */ private fun DescriptorProtos.FileDescriptorProto.toDescriptor( protoFileMap: Map, - cache: MutableMap + cache: MutableMap, ): Descriptors.FileDescriptor { if (cache.containsKey(name)) return cache[name]!! diff --git a/protoc-gen/grpc/src/main/kotlin/kotlinx/rpc/protoc/gen/grpc/ModelToGrpcKotlinCommonGenerator.kt b/protoc-gen/grpc/src/main/kotlin/kotlinx/rpc/protoc/gen/grpc/ModelToGrpcKotlinCommonGenerator.kt index e7e51a13e..d2a62b904 100644 --- a/protoc-gen/grpc/src/main/kotlin/kotlinx/rpc/protoc/gen/grpc/ModelToGrpcKotlinCommonGenerator.kt +++ b/protoc-gen/grpc/src/main/kotlin/kotlinx/rpc/protoc/gen/grpc/ModelToGrpcKotlinCommonGenerator.kt @@ -20,14 +20,11 @@ class ModelToGrpcKotlinCommonGenerator( override val FileDeclaration.hasInternalGeneratedContent: Boolean get() = false override fun CodeGenerator.generatePublicDeclaredEntities(fileDeclaration: FileDeclaration) { + additionalPublicImports.add("kotlinx.coroutines.flow.Flow") fileDeclaration.serviceDeclarations.forEach { generatePublicService(it) } } - override fun CodeGenerator.generateInternalDeclaredEntities(fileDeclaration: FileDeclaration) { } - - init { - additionalPublicImports.add("kotlinx.coroutines.flow.Flow") - } + override fun CodeGenerator.generateInternalDeclaredEntities(fileDeclaration: FileDeclaration) {} @Suppress("detekt.LongMethod") private fun CodeGenerator.generatePublicService(service: ServiceDeclaration) { From aeb874d3d2b08556e7ae8141a54a26acc3a56e6b Mon Sep 17 00:00:00 2001 From: Johannes Zottele Date: Mon, 18 Aug 2025 16:57:03 +0200 Subject: [PATCH 09/14] grpc-native: Remove unnecessary C header definitions Signed-off-by: Johannes Zottele --- cinterop-c/include/grpcpp_c.h | 75 ++----- cinterop-c/include/protowire.h | 4 + cinterop-c/src/grpcpp_c.cpp | 203 +----------------- cinterop-c/src/protowire.cpp | 6 +- .../rpc/grpc/internal/NativeManagedChannel.kt | 3 +- .../grpc/internal/bridge/GrpcByteBuffer.kt | 35 --- .../rpc/grpc/internal/bridge/GrpcClient.kt | 73 ------- .../rpc/grpc/internal/bridge/GrpcSlice.kt | 31 --- .../grpc/internal/UnexpectedCleanerTest.kt | 64 ------ .../kotlinx/rpc/grpc/test/BridgeTest.kt | 39 ---- 10 files changed, 26 insertions(+), 507 deletions(-) delete mode 100644 grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/bridge/GrpcByteBuffer.kt delete mode 100644 grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/bridge/GrpcClient.kt delete mode 100644 grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/bridge/GrpcSlice.kt delete mode 100644 grpc/grpc-core/src/nativeTest/kotlin/kotlinx/rpc/grpc/internal/UnexpectedCleanerTest.kt delete mode 100644 grpc/grpc-core/src/nativeTest/kotlin/kotlinx/rpc/grpc/test/BridgeTest.kt diff --git a/cinterop-c/include/grpcpp_c.h b/cinterop-c/include/grpcpp_c.h index 48a5f6a05..805431152 100644 --- a/cinterop-c/include/grpcpp_c.h +++ b/cinterop-c/include/grpcpp_c.h @@ -1,82 +1,35 @@ -// -// Created by Johannes Zottele on 11.07.25. -// +/* + * Copyright 2023-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +/* + * Helper functions required for gRPC Core cinterop. + */ #ifndef GRPCPP_C_H #define GRPCPP_C_H -#include #include #include -#include -#include #ifdef __cplusplus extern "C" { #endif -typedef struct grpc_client grpc_client_t; -typedef struct grpc_method grpc_method_t; -typedef struct grpc_context grpc_context_t; - -typedef enum StatusCode { - GRPC_C_STATUS_OK = 0, - GRPC_C_STATUS_CANCELLED = 1, - GRPC_C_STATUS_UNKNOWN = 2, - GRPC_C_STATUS_INVALID_ARGUMENT = 3, - GRPC_C_STATUS_DEADLINE_EXCEEDED = 4, - GRPC_C_STATUS_NOT_FOUND = 5, - GRPC_C_STATUS_ALREADY_EXISTS = 6, - GRPC_C_STATUS_PERMISSION_DENIED = 7, - GRPC_C_STATUS_UNAUTHENTICATED = 16, - GRPC_C_STATUS_RESOURCE_EXHAUSTED = 8, - GRPC_C_STATUS_FAILED_PRECONDITION = 9, - GRPC_C_STATUS_ABORTED = 10, - GRPC_C_STATUS_OUT_OF_RANGE = 11, - GRPC_C_STATUS_UNIMPLEMENTED = 12, - GRPC_C_STATUS_INTERNAL = 13, - GRPC_C_STATUS_UNAVAILABLE = 14, - GRPC_C_STATUS_DATA_LOSS = 15, - GRPC_C_STATUS_DO_NOT_USE = -1 -} grpc_status_code_t; - - +/* + * Struct that layouts a grpc_completion_queue_functor and user opaque data pointer, + * to implement the callback mechanism in the K/N CompletionQueue. + */ typedef struct { grpc_completion_queue_functor functor; void *user_data; } grpc_cb_tag; - -grpc_client_t *grpc_client_create_insecure(const char *target); -void grpc_client_delete(const grpc_client_t *client); - -grpc_method_t *grpc_method_create(const char *method_name); -void grpc_method_delete(const grpc_method_t *method); - -const char *grpc_method_name(const grpc_method_t *method); - -grpc_context_t *grpc_context_create(); -void grpc_context_delete(const grpc_context_t *context); - -grpc_status_code_t grpc_client_call_unary_blocking(grpc_client_t *client, const char *method, - grpc_slice req_slice, grpc_slice *resp_slice); - -void grpc_client_call_unary_callback(grpc_client_t *client, grpc_method_t *method, grpc_context_t *context, - grpc_byte_buffer **req_buf, grpc_byte_buffer **resp_buf, void* callback_context, void (*callback)(grpc_status_code_t,void*)); - -uint32_t pb_decode_greeter_sayhello_response(grpc_slice response); - -grpc_status_code_t grpc_byte_buffer_dump_to_single_slice(grpc_byte_buffer *byte_buffer, grpc_slice *slice); - - -/////// CHANNEL /////// - -typedef struct grpc_channel grpc_channel_t; -typedef struct grpc_channel_credentials grpc_channel_credentials_t; - +/* + * Call to grpc_iomgr_run_in_background(), which is not exposed as extern "C" and therefore must be wrapped. + */ bool kgrpc_iomgr_run_in_background(); - #ifdef __cplusplus } #endif diff --git a/cinterop-c/include/protowire.h b/cinterop-c/include/protowire.h index 87435e488..20dff1288 100644 --- a/cinterop-c/include/protowire.h +++ b/cinterop-c/include/protowire.h @@ -1,3 +1,7 @@ +/* + * Copyright 2023-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + #ifndef PROTOWIRE_H #define PROTOWIRE_H diff --git a/cinterop-c/src/grpcpp_c.cpp b/cinterop-c/src/grpcpp_c.cpp index b06e82ca2..84ade46ad 100644 --- a/cinterop-c/src/grpcpp_c.cpp +++ b/cinterop-c/src/grpcpp_c.cpp @@ -1,214 +1,17 @@ -// -// Created by Johannes Zottele on 11.07.25. -// +/* + * Copyright 2023-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ #include -#include -#include -#include -#include -#include -#include #include "src/core/lib/iomgr/iomgr.h" -namespace pb = google::protobuf; - -struct grpc_client { - std::shared_ptr channel; - std::unique_ptr stub; -}; - -struct grpc_method { - std::string name_str; - std::unique_ptr method; -}; - -struct grpc_context { - std::unique_ptr context; -}; - -// struct grpc_channel { -// std::shared_ptr channel; -// }; - extern "C" { - grpc_client_t *grpc_client_create_insecure(const char *target) { - std::string target_str = target; - auto client = new grpc_client; - client->channel = grpc::CreateChannel(target_str, grpc::InsecureChannelCredentials()); - client->stub = std::make_unique(client->channel); - return client; - } - - void grpc_client_delete(const grpc_client_t *client) { - delete client; - } - - grpc_method_t *grpc_method_create(const char *method_name) { - auto *method = new grpc_method; - method->name_str = method_name; - method->method = std::make_unique(method->name_str.c_str(), grpc::internal::RpcMethod::NORMAL_RPC); - return method; - } - - void grpc_method_delete(const grpc_method_t *method) { - delete method; - } - - const char *grpc_method_name(const grpc_method_t *method) { - return method->method->name(); - } - - grpc_context_t *grpc_context_create() { - auto *context = new grpc_context; - context->context = std::make_unique(); - return context; - } - - void grpc_context_delete(const grpc_context_t *context) { - delete context; - } - - static grpc_status_code_t status_to_c(grpc::StatusCode status); - - grpc_status_code_t grpc_client_call_unary_blocking(grpc_client_t *client, const char *method, - grpc_slice req_slice, grpc_slice *resp_slice) { - - if (!client || !method) return GRPC_C_STATUS_INVALID_ARGUMENT; - - grpc::Slice cc_req_slice(req_slice, grpc::Slice::ADD_REF); - grpc::ByteBuffer req_bb(&cc_req_slice, 1); - - grpc::ClientContext context; - grpc::ByteBuffer resp_bb; - - const std::string method_path = "/Greeter/SayHello"; - grpc::internal::RpcMethod rpc(method_path.c_str(), - grpc::internal::RpcMethod::NORMAL_RPC); - - grpc::Status st = - grpc::internal::BlockingUnaryCall( - client->channel.get(), rpc, &context, req_bb, &resp_bb); - - - if (!st.ok()) { - // if not ok, no resp_buf is left null - return status_to_c(st.error_code()); - } - - grpc::Slice cc_resp_slice; - resp_bb.DumpToSingleSlice(&cc_resp_slice); - *resp_slice = cc_resp_slice.c_slice(); - - grpc::Slice test_slice(*resp_slice, grpc::Slice::ADD_REF); - pb::io::ArrayInputStream ais(test_slice.begin(), test_slice.size()); - pb::io::CodedInputStream cis(&ais); - - - cis.ReadTag(); - uint32_t id = 0; - if (!cis.ReadVarint32(&id)) { - std::cerr << "Failed to read id field\n"; - } - - return status_to_c(st.error_code()); - } - - void grpc_client_call_unary_callback(grpc_client_t *client, grpc_method_t *method, grpc_context_t *context, - grpc_byte_buffer **req_buf, grpc_byte_buffer **resp_buf, void* callback_context, void (*callback)(grpc_status_code_t,void*)) { - // the grpc::ByteBuffer representation is identical to (* grpc_byte_buffer) so we can safely cast it. - // so a **grpc_byte_buffer can be cast to *grpc::ByteBuffer. - static_assert(sizeof(grpc::ByteBuffer) == sizeof(grpc_byte_buffer*), - "ByteBuffer must have same representation as " - "grpc_byte_buffer*"); - const auto req_bb = reinterpret_cast(req_buf); - const auto resp_bb = reinterpret_cast(resp_buf); - grpc::internal::CallbackUnaryCall(client->channel.get(), *method->method, context->context.get(), req_bb, resp_bb, [callback, callback_context](grpc::Status st) { - const auto c_st = status_to_c(st.error_code()); - callback(c_st, callback_context); - }); - } - - grpc_status_code_t status_to_c(grpc::StatusCode status) { - switch (status) { - case grpc::OK: - return GRPC_C_STATUS_OK; - case grpc::CANCELLED: - return GRPC_C_STATUS_CANCELLED; - case grpc::UNKNOWN: - return GRPC_C_STATUS_UNKNOWN; - case grpc::INVALID_ARGUMENT: - return GRPC_C_STATUS_INVALID_ARGUMENT; - case grpc::DEADLINE_EXCEEDED: - return GRPC_C_STATUS_DEADLINE_EXCEEDED; - case grpc::NOT_FOUND: - return GRPC_C_STATUS_NOT_FOUND; - case grpc::ALREADY_EXISTS: - return GRPC_C_STATUS_ALREADY_EXISTS; - case grpc::PERMISSION_DENIED: - return GRPC_C_STATUS_PERMISSION_DENIED; - case grpc::UNAUTHENTICATED: - return GRPC_C_STATUS_UNAUTHENTICATED; - case grpc::RESOURCE_EXHAUSTED: - return GRPC_C_STATUS_RESOURCE_EXHAUSTED; - case grpc::FAILED_PRECONDITION: - return GRPC_C_STATUS_FAILED_PRECONDITION; - case grpc::ABORTED: - return GRPC_C_STATUS_ABORTED; - case grpc::UNIMPLEMENTED: - return GRPC_C_STATUS_UNIMPLEMENTED; - case grpc::OUT_OF_RANGE: - return GRPC_C_STATUS_OUT_OF_RANGE; - case grpc::INTERNAL: - return GRPC_C_STATUS_INTERNAL; - case grpc::UNAVAILABLE: - return GRPC_C_STATUS_UNAVAILABLE; - case grpc::DATA_LOSS: - return GRPC_C_STATUS_DATA_LOSS; - case grpc::DO_NOT_USE: - return GRPC_C_STATUS_DO_NOT_USE; - } - } - - - uint32_t pb_decode_greeter_sayhello_response(grpc_slice response) { - grpc::Slice cc_resp_slice(response, grpc::Slice::ADD_REF); - pb::io::ArrayInputStream asi(cc_resp_slice.begin(), cc_resp_slice.size()); - pb::io::CodedInputStream cis(&asi); - - const auto tag = cis.ReadTag(); - if (tag != 8) { - std::cerr << "Failed to read tag. Got: " << tag << std::endl; - } - - uint32_t result; - if (!cis.ReadVarint32(&result)) { - std::cerr << "Failed to read result" << std::endl; - } else { - - } - return result; - } - - - grpc_status_code_t grpc_byte_buffer_dump_to_single_slice(grpc_byte_buffer *byte_buffer, grpc_slice *slice) { - auto bb = reinterpret_cast(&byte_buffer); - grpc::Slice cc_slice; - bb->DumpToSingleSlice(&cc_slice); - *slice = cc_slice.c_slice(); - return GRPC_C_STATUS_OK; - } - - - //// CHANNEL //// - bool kgrpc_iomgr_run_in_background() { return grpc_iomgr_run_in_background(); } - } diff --git a/cinterop-c/src/protowire.cpp b/cinterop-c/src/protowire.cpp index 1a9f35ffa..e48fb768f 100644 --- a/cinterop-c/src/protowire.cpp +++ b/cinterop-c/src/protowire.cpp @@ -1,6 +1,6 @@ -// -// Created by Johannes Zottele on 17.07.25. -// +/* + * Copyright 2023-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ #include "protowire.h" diff --git a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeManagedChannel.kt b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeManagedChannel.kt index 28f767356..ccfe47213 100644 --- a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeManagedChannel.kt +++ b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeManagedChannel.kt @@ -7,6 +7,7 @@ package kotlinx.rpc.grpc.internal import cnames.structs.grpc_channel +import cnames.structs.grpc_channel_credentials import kotlinx.cinterop.CPointer import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.coroutines.* @@ -19,7 +20,7 @@ import kotlin.native.ref.createCleaner import kotlin.time.Duration internal sealed class GrpcCredentials( - internal val raw: CPointer, + internal val raw: CPointer, ) { val rawCleaner = createCleaner(raw) { grpc_channel_credentials_release(it) diff --git a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/bridge/GrpcByteBuffer.kt b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/bridge/GrpcByteBuffer.kt deleted file mode 100644 index f5df196b2..000000000 --- a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/bridge/GrpcByteBuffer.kt +++ /dev/null @@ -1,35 +0,0 @@ -/* - * 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.internal.bridge - -import kotlinx.cinterop.* -import libgrpcpp_c.* -import kotlin.experimental.ExperimentalNativeApi -import kotlin.native.ref.createCleaner - -@OptIn(ExperimentalForeignApi::class, ExperimentalNativeApi::class) -internal class GrpcByteBuffer internal constructor( - internal val cByteBuffer: CPointer -) { - - constructor(slice: GrpcSlice) : this(memScoped { - grpc_raw_byte_buffer_create(slice.cSlice, 1u) ?: error("Failed to create byte buffer") - }) - - init { - createCleaner(cByteBuffer) { - grpc_byte_buffer_destroy(it) - } - } - - fun intoSlice(): GrpcSlice { - memScoped { - val respSlice = alloc() - grpc_byte_buffer_dump_to_single_slice(cByteBuffer, respSlice.ptr) - return GrpcSlice(respSlice.readValue()) - } - } - -} diff --git a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/bridge/GrpcClient.kt b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/bridge/GrpcClient.kt deleted file mode 100644 index 642712dfa..000000000 --- a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/bridge/GrpcClient.kt +++ /dev/null @@ -1,73 +0,0 @@ -/* - * 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.internal.bridge - -import kotlinx.cinterop.* -import kotlinx.coroutines.suspendCancellableCoroutine -import libgrpcpp_c.* -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException -import kotlin.experimental.ExperimentalNativeApi -import kotlin.native.ref.createCleaner - -@OptIn(ExperimentalForeignApi::class, ExperimentalNativeApi::class) -internal class GrpcClient(target: String) { - private var clientPtr: CPointer = - grpc_client_create_insecure(target) ?: error("Failed to create client") - - init { - createCleaner(clientPtr) { - grpc_client_delete(it) - } - } - - fun callUnaryBlocking(method: String, req: GrpcSlice): GrpcSlice { - memScoped { - val result = alloc() - grpc_client_call_unary_blocking(clientPtr, method, req.cSlice, result.ptr) - return GrpcSlice(result.readValue()) - } - } - - suspend fun callUnary(method: String, req: GrpcByteBuffer): GrpcByteBuffer = - suspendCancellableCoroutine { continuation -> - val context = grpc_context_create() - val method = grpc_method_create(method) - - val reqRawBuf = nativeHeap.alloc>() - reqRawBuf.value = req.cByteBuffer - - val respRawBuf: CPointerVar = nativeHeap.alloc() - - val continueCb = { st: grpc_status_code_t -> - // cleanup allocations owned by this method (this runs always) - grpc_method_delete(method) - grpc_context_delete(context) - nativeHeap.free(reqRawBuf) - - if (st != GRPC_C_STATUS_OK) { - continuation.resumeWithException(RuntimeException("Call failed with code: $st")) - } else { - val result = respRawBuf.value - if (result == null) { - continuation.resumeWithException(RuntimeException("No response received")) - } else { - continuation.resume(GrpcByteBuffer(result)) - } - } - - nativeHeap.free(respRawBuf) - } - val cbCtxStable = StableRef.create(continueCb) - - grpc_client_call_unary_callback( - clientPtr, method, context, reqRawBuf.ptr, respRawBuf.ptr, - cbCtxStable.asCPointer(), staticCFunction { st, ctx -> - val cbCtxStable = ctx!!.asStableRef<(grpc_status_code_t) -> Unit>() - cbCtxStable.get()(st) - cbCtxStable.dispose() - }) - } -} diff --git a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/bridge/GrpcSlice.kt b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/bridge/GrpcSlice.kt deleted file mode 100644 index 70ab9a515..000000000 --- a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/bridge/GrpcSlice.kt +++ /dev/null @@ -1,31 +0,0 @@ -/* - * 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.internal.bridge - -import kotlinx.cinterop.CValue -import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.cinterop.addressOf -import kotlinx.cinterop.usePinned -import libgrpcpp_c.grpc_slice -import libgrpcpp_c.grpc_slice_from_copied_buffer -import libgrpcpp_c.grpc_slice_unref -import kotlin.experimental.ExperimentalNativeApi -import kotlin.native.ref.createCleaner - -@OptIn(ExperimentalForeignApi::class, ExperimentalNativeApi::class) -internal class GrpcSlice internal constructor(internal val cSlice: CValue) { - - constructor(buffer: ByteArray) : this( - buffer.usePinned { pinned -> - grpc_slice_from_copied_buffer(pinned.addressOf(0), buffer.size.toULong()) - } - ) - - init { - createCleaner(cSlice) { - grpc_slice_unref(it) - } - } -} diff --git a/grpc/grpc-core/src/nativeTest/kotlin/kotlinx/rpc/grpc/internal/UnexpectedCleanerTest.kt b/grpc/grpc-core/src/nativeTest/kotlin/kotlinx/rpc/grpc/internal/UnexpectedCleanerTest.kt deleted file mode 100644 index 77f8f0fe8..000000000 --- a/grpc/grpc-core/src/nativeTest/kotlin/kotlinx/rpc/grpc/internal/UnexpectedCleanerTest.kt +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright 2023-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. - */ - -@file:OptIn(ExperimentalForeignApi::class, ExperimentalNativeApi::class, ObsoleteWorkersApi::class) - -package kotlinx.rpc.grpc.internal - -import kotlinx.cinterop.* -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.suspendCancellableCoroutine -import platform.posix.sleep -import kotlin.coroutines.Continuation -import kotlin.coroutines.resume -import kotlin.experimental.ExperimentalNativeApi -import kotlin.native.concurrent.ObsoleteWorkersApi -import kotlin.native.concurrent.TransferMode -import kotlin.native.concurrent.Worker -import kotlin.native.ref.createCleaner -import kotlin.test.Test -import kotlin.time.TimeSource - -fun startApiCall(callBack: CPointer Unit>>, ctx: COpaquePointer) { - val worker = Worker.start() - - worker.execute(TransferMode.SAFE, { Pair(callBack, ctx) }) { (cbPtr, ctx) -> - sleep(15u) - cbPtr.invoke(ctx) - } -} - -class MyCallbackApiWrapper { - - val myResource = Any() - val myResourceCleaner = createCleaner(myResource) { - // clean my resource - val timeSinceStart = TimeSource.Monotonic.markNow() - println("$timeSinceStart: My resource got cleaned") - } - - val callback = staticCFunction { ptr: COpaquePointer -> - val stableRef = ptr.asStableRef>() - stableRef.get().resume(Unit) - stableRef.dispose() - } - - suspend fun callMyApi() = suspendCancellableCoroutine { cont -> - val contRef = StableRef.create(cont) - startApiCall(callback, contRef.asCPointer()) - } -} - - -class UnexpectedCleanerTest { - @Test - fun test() { - runBlocking { - val apiWrapper = MyCallbackApiWrapper() - apiWrapper.callMyApi() - val timeSinceStart = TimeSource.Monotonic.markNow() - println("$timeSinceStart: My API call returned") - } - } -} \ No newline at end of file diff --git a/grpc/grpc-core/src/nativeTest/kotlin/kotlinx/rpc/grpc/test/BridgeTest.kt b/grpc/grpc-core/src/nativeTest/kotlin/kotlinx/rpc/grpc/test/BridgeTest.kt deleted file mode 100644 index e0ef885b2..000000000 --- a/grpc/grpc-core/src/nativeTest/kotlin/kotlinx/rpc/grpc/test/BridgeTest.kt +++ /dev/null @@ -1,39 +0,0 @@ -/* - * 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 - -import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.coroutines.runBlocking -import kotlinx.rpc.grpc.internal.bridge.GrpcByteBuffer -import kotlinx.rpc.grpc.internal.bridge.GrpcClient -import kotlinx.rpc.grpc.internal.bridge.GrpcSlice -import libgrpcpp_c.pb_decode_greeter_sayhello_response -import kotlin.native.runtime.GC -import kotlin.native.runtime.NativeRuntimeApi -import kotlin.test.Test -import kotlin.test.fail - -@OptIn(ExperimentalForeignApi::class) -class BridgeTest { - - @OptIn(NativeRuntimeApi::class) - @Test - fun testBasicUnaryAsyncCall() = runBlocking { - try { - val client = GrpcClient("localhost:50051") - val request = GrpcSlice(byteArrayOf(8, 4)) - val reqBuf = GrpcByteBuffer(request) - val result = client.callUnary("/Greeter/SayHello", reqBuf) - val response = result.intoSlice() - val value = pb_decode_greeter_sayhello_response(response.cSlice) - println("Response received: $value") - } catch (e: Exception) { - // trigger GC collection, otherwise there will be a leak - GC.collect() - fail("Got an exception: ${e.message}", e) - } - } - -} From f296a61a7e5e9bdc698c0890b468feda70a1f5be Mon Sep 17 00:00:00 2001 From: Johannes Zottele Date: Mon, 18 Aug 2025 17:05:38 +0200 Subject: [PATCH 10/14] grpc-native: Rename grpcpp_c to kgrpc Signed-off-by: Johannes Zottele --- cinterop-c/BUILD.bazel | 12 ++++++------ cinterop-c/include/{grpcpp_c.h => kgrpc.h} | 2 +- cinterop-c/src/{grpcpp_c.cpp => kgrpc.cpp} | 2 +- grpc/grpc-core/build.gradle.kts | 4 ++-- .../cinterop/{libgrpcpp_c.def => libkgrpc.def} | 6 +++--- .../rpc/grpc/internal/CompletionQueue.kt | 18 +++++++++--------- .../rpc/grpc/internal/NativeClientCall.kt | 2 +- .../rpc/grpc/internal/NativeGrpcLibrary.kt | 4 ++-- .../rpc/grpc/internal/NativeManagedChannel.kt | 2 +- .../kotlin/kotlinx/rpc/grpc/internal/utils.kt | 2 +- 10 files changed, 27 insertions(+), 27 deletions(-) rename cinterop-c/include/{grpcpp_c.h => kgrpc.h} (97%) rename cinterop-c/src/{grpcpp_c.cpp => kgrpc.cpp} (92%) rename grpc/grpc-core/src/nativeInterop/cinterop/{libgrpcpp_c.def => libkgrpc.def} (61%) diff --git a/cinterop-c/BUILD.bazel b/cinterop-c/BUILD.bazel index 6d88c90ec..c21d9b613 100644 --- a/cinterop-c/BUILD.bazel +++ b/cinterop-c/BUILD.bazel @@ -1,16 +1,16 @@ load("@rules_cc//cc:defs.bzl", "cc_library") cc_static_library( - name = "grpcpp_c_static", + name = "kgrpc_static", deps = [ - ":grpcpp_c", + ":kgrpc", ], ) cc_library( - name = "grpcpp_c", - srcs = ["src/grpcpp_c.cpp"], - hdrs = glob(["include/**/*.h"]), + name = "kgrpc", + srcs = ["src/kgrpc.cpp"], + hdrs = glob(["include/kgrpc.h"]), copts = ["-std=c++20"], includes = ["include"], visibility = ["//visibility:public"], @@ -35,7 +35,7 @@ cc_static_library( cc_library( name = "protowire", srcs = ["src/protowire.cpp"], - hdrs = glob(["include/*.h"]), + hdrs = glob(["include/protowire.h"]), copts = ["-std=c++20"], includes = ["include"], visibility = ["//visibility:public"], diff --git a/cinterop-c/include/grpcpp_c.h b/cinterop-c/include/kgrpc.h similarity index 97% rename from cinterop-c/include/grpcpp_c.h rename to cinterop-c/include/kgrpc.h index 805431152..5c2d72e19 100644 --- a/cinterop-c/include/grpcpp_c.h +++ b/cinterop-c/include/kgrpc.h @@ -23,7 +23,7 @@ extern "C" { typedef struct { grpc_completion_queue_functor functor; void *user_data; -} grpc_cb_tag; +} kgrpc_cb_tag; /* * Call to grpc_iomgr_run_in_background(), which is not exposed as extern "C" and therefore must be wrapped. diff --git a/cinterop-c/src/grpcpp_c.cpp b/cinterop-c/src/kgrpc.cpp similarity index 92% rename from cinterop-c/src/grpcpp_c.cpp rename to cinterop-c/src/kgrpc.cpp index 84ade46ad..cd860db75 100644 --- a/cinterop-c/src/grpcpp_c.cpp +++ b/cinterop-c/src/kgrpc.cpp @@ -2,7 +2,7 @@ * Copyright 2023-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. */ -#include +#include #include "src/core/lib/iomgr/iomgr.h" diff --git a/grpc/grpc-core/build.gradle.kts b/grpc/grpc-core/build.gradle.kts index 85432d86f..828428656 100644 --- a/grpc/grpc-core/build.gradle.kts +++ b/grpc/grpc-core/build.gradle.kts @@ -70,9 +70,9 @@ kotlin { } } - configureCLibCInterop(project, ":grpcpp_c_static") { cinteropCLib -> + configureCLibCInterop(project, ":kgrpc_static") { cinteropCLib -> @Suppress("unused") - val libgrpcpp_c by creating { + val libkgrpc by creating { includeDirs( cinteropCLib.resolve("include"), cinteropCLib.resolve("bazel-cinterop-c/external/grpc+/include"), diff --git a/grpc/grpc-core/src/nativeInterop/cinterop/libgrpcpp_c.def b/grpc/grpc-core/src/nativeInterop/cinterop/libkgrpc.def similarity index 61% rename from grpc/grpc-core/src/nativeInterop/cinterop/libgrpcpp_c.def rename to grpc/grpc-core/src/nativeInterop/cinterop/libkgrpc.def index 84ea16026..38a1c133f 100644 --- a/grpc/grpc-core/src/nativeInterop/cinterop/libgrpcpp_c.def +++ b/grpc/grpc-core/src/nativeInterop/cinterop/libkgrpc.def @@ -1,11 +1,11 @@ -headers = grpcpp_c.h grpc/grpc.h grpc/credentials.h grpc/byte_buffer_reader.h \ +headers = kgrpc.h grpc/grpc.h grpc/credentials.h grpc/byte_buffer_reader.h \ grpc/support/alloc.h -headerFilter= grpcpp_c.h grpc/slice.h grpc/byte_buffer.h grpc/grpc.h \ +headerFilter= kgrpc.h grpc/slice.h grpc/byte_buffer.h grpc/grpc.h \ grpc/impl/grpc_types.h grpc/credentials.h grpc/support/time.h grpc/byte_buffer_reader.h \ grpc/support/alloc.h noStringConversion = grpc_slice_from_copied_buffer my_grpc_slice_from_copied_buffer strictEnums = grpc_status_code grpc_connectivity_state grpc_call_error -staticLibraries = libgrpcpp_c_static.a +staticLibraries = libkgrpc_static.a 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 f050bc4ae..ba5d0bc64 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 @@ -10,7 +10,7 @@ import kotlinx.atomicfu.atomic import kotlinx.atomicfu.locks.SynchronizedObject import kotlinx.atomicfu.locks.synchronized import kotlinx.cinterop.* -import libgrpcpp_c.* +import libkgrpc.* import platform.posix.memset import kotlin.experimental.ExperimentalNativeApi import kotlin.native.ref.createCleaner @@ -49,7 +49,7 @@ internal class CompletionQueue { private val thisStableRef = StableRef.create(this) - private val shutdownFunctor = nativeHeap.alloc { + private val shutdownFunctor = nativeHeap.alloc { functor.functor_run = SHUTDOWN_CB user_data = thisStableRef.asCPointer() }.reinterpret() @@ -127,7 +127,7 @@ internal class CompletionQueue { // kq stands for kompletion_queue lol @CName("kq_ops_complete_cb") private fun opsCompleteCb(functor: CPointer?, ok: Int) { - val tag = functor!!.reinterpret() + val tag = functor!!.reinterpret() val cont = tag.pointed.user_data!!.asStableRef>().get() deleteCbTag(tag) cont.complete(ok != 0) @@ -135,7 +135,7 @@ private fun opsCompleteCb(functor: CPointer?, ok: @CName("kq_shutdown_cb") private fun shutdownCb(functor: CPointer?, ok: Int) { - val tag = functor!!.reinterpret() + val tag = functor!!.reinterpret() val cq = tag.pointed.user_data!!.asStableRef().get() cq._shutdownDone.complete(Unit) cq._state.value = CompletionQueue.State.CLOSED @@ -148,16 +148,16 @@ private val SHUTDOWN_CB = staticCFunction(::shutdownCb) private fun newCbTag( userData: Any, cb: CPointer?, Int) -> Unit>>, -): CPointer { - val tag = nativeHeap.alloc() - memset(tag.ptr, 0, sizeOf().convert()) +): CPointer { + val tag = nativeHeap.alloc() + memset(tag.ptr, 0, sizeOf().convert()) tag.functor.functor_run = cb tag.user_data = StableRef.create(userData).asCPointer() return tag.ptr } -@CName("grpc_cb_tag_destroy") -private fun deleteCbTag(tag: CPointer) { +@CName("kgrpc_cb_tag_destroy") +private fun deleteCbTag(tag: CPointer) { tag.pointed.user_data!!.asStableRef().dispose() nativeHeap.free(tag) } \ No newline at end of file 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 556bb92a5..3074caf6b 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 @@ -16,7 +16,7 @@ import kotlinx.rpc.grpc.Status import kotlinx.rpc.grpc.StatusCode import kotlinx.rpc.protobuf.input.stream.asInputStream import kotlinx.rpc.protobuf.input.stream.asSource -import libgrpcpp_c.* +import libkgrpc.* import kotlin.experimental.ExperimentalNativeApi import kotlin.native.ref.createCleaner diff --git a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeGrpcLibrary.kt b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeGrpcLibrary.kt index f6fd79ee9..98c0aeac5 100644 --- a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeGrpcLibrary.kt +++ b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeGrpcLibrary.kt @@ -10,8 +10,8 @@ import kotlinx.atomicfu.atomic import kotlinx.atomicfu.locks.reentrantLock import kotlinx.atomicfu.locks.withLock import kotlinx.cinterop.ExperimentalForeignApi -import libgrpcpp_c.grpc_init -import libgrpcpp_c.grpc_shutdown +import libkgrpc.grpc_init +import libkgrpc.grpc_shutdown import kotlin.experimental.ExperimentalNativeApi internal object GrpcRuntime { diff --git a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeManagedChannel.kt b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeManagedChannel.kt index ccfe47213..ceea6e4ec 100644 --- a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeManagedChannel.kt +++ b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeManagedChannel.kt @@ -13,7 +13,7 @@ import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.coroutines.* import kotlinx.rpc.grpc.ManagedChannel import kotlinx.rpc.grpc.ManagedChannelPlatform -import libgrpcpp_c.* +import libkgrpc.* import kotlin.coroutines.cancellation.CancellationException import kotlin.experimental.ExperimentalNativeApi import kotlin.native.ref.createCleaner 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 d661fe0de..04bb5fcbf 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 @@ -10,7 +10,7 @@ import kotlinx.cinterop.* import kotlinx.io.* import kotlinx.io.unsafe.UnsafeBufferOperations import kotlinx.rpc.grpc.StatusCode -import libgrpcpp_c.* +import libkgrpc.* import platform.posix.memcpy @OptIn(ExperimentalForeignApi::class, InternalIoApi::class, UnsafeIoApi::class) From 3aea999274b088d900c5f2895eb7fc266da66af4 Mon Sep 17 00:00:00 2001 From: Johannes Zottele Date: Mon, 18 Aug 2025 17:11:30 +0200 Subject: [PATCH 11/14] grpc-native: Reduce library dependencies (fixes KRPC-185) Signed-off-by: Johannes Zottele --- cinterop-c/BUILD.bazel | 8 +------- cinterop-c/MODULE.bazel | 4 ++-- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/cinterop-c/BUILD.bazel b/cinterop-c/BUILD.bazel index c21d9b613..4bb16b167 100644 --- a/cinterop-c/BUILD.bazel +++ b/cinterop-c/BUILD.bazel @@ -15,13 +15,7 @@ cc_library( includes = ["include"], visibility = ["//visibility:public"], deps = [ - # TODO: Reduce the dependencies and only use required once. KRPC-185 - "@com_github_grpc_grpc//:grpc", - "@com_github_grpc_grpc//:channelz", - "@com_github_grpc_grpc//:generic_stub", - "@com_github_grpc_grpc//:grpc++", - "@com_github_grpc_grpc//:grpc_credentials_util", - "@com_google_protobuf//:protobuf", + "@com_github_grpc_grpc//:grpc" ], ) diff --git a/cinterop-c/MODULE.bazel b/cinterop-c/MODULE.bazel index 136cad3ae..a9d45ecc3 100644 --- a/cinterop-c/MODULE.bazel +++ b/cinterop-c/MODULE.bazel @@ -1,5 +1,5 @@ module( - name = "grpcpp_c", + name = "kgrpc", version = "0.1", ) @@ -16,7 +16,7 @@ bazel_dep( repo_name = "com_google_protobuf", ) -# gRPC C++ library +# gRPC library bazel_dep( name = "grpc", version = "1.73.1", From 99b9dcfac23b10dd272d1c9bd72d9fc7018a9b86 Mon Sep 17 00:00:00 2001 From: Johannes Zottele Date: Mon, 18 Aug 2025 18:08:19 +0200 Subject: [PATCH 12/14] grpc-native: Write docs Signed-off-by: Johannes Zottele --- .../kotlinx/rpc/grpc/internal/ClientCall.kt | 6 ++ ...ellationClientTest.kt => RawClientTest.kt} | 30 +++++++-- .../rpc/grpc/internal/CompletionQueue.kt | 36 ++++++++++- .../rpc/grpc/internal/NativeClientCall.kt | 63 ++++++++++++++++--- .../rpc/grpc/internal/NativeManagedChannel.kt | 17 ++++- 5 files changed, 137 insertions(+), 15 deletions(-) rename grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/{CancellationClientTest.kt => RawClientTest.kt} (76%) 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 d90b2b1c2..e7128a5cc 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 @@ -16,6 +16,12 @@ import kotlinx.rpc.internal.utils.InternalRpcApi * Callback execution: * - On JVM it is guaranteed that callbacks aren't executed concurrently. * - On Native, it is only guaranteed that `onClose` is called after all other callbacks finished. + * + * Sending message readiness: + * - On JVM, it is possible to call [sendMessage] multiple times, without checking [isReady]. + * Internally, it buffers the messages. + * - On Native, you can only call [sendMessage] when [isReady] returns true. There is no buffering; therefore, + * only one message can be sent at a time. */ @InternalRpcApi public expect abstract class ClientCall { diff --git a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/CancellationClientTest.kt b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/RawClientTest.kt similarity index 76% rename from grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/CancellationClientTest.kt rename to grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/RawClientTest.kt index a01d41996..b8bb4aed1 100644 --- a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/CancellationClientTest.kt +++ b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/RawClientTest.kt @@ -14,7 +14,10 @@ import kotlinx.rpc.grpc.internal.* import kotlin.test.Test import kotlin.test.assertEquals -class CancellationClientTest { +/** + * Tests for JVM and Native clients. + */ +class RawClientTest { @Test fun unaryEchoTest() = runTest( @@ -39,8 +42,8 @@ class CancellationClientTest { } @Test - fun clientStreamingTest() = runTest( - methodName = "ServerStreamingEcho", + fun clientStreamingEchoTest() = runTest( + methodName = "ClientStreamingEcho", type = MethodType.CLIENT_STREAMING, ) { channel, descriptor -> val response = clientStreamingRpc(channel, descriptor, flow { @@ -50,10 +53,29 @@ class CancellationClientTest { emit(EchoRequest { message = "Eccchhooo" }) } }) - val expected = "Eccchhooo,Eccchhooo,Eccchhooo,Eccchhooo,Eccchhooo" + val expected = "Eccchhooo, Eccchhooo, Eccchhooo, Eccchhooo, Eccchhooo" assertEquals(expected, response.message) } + @Test + fun bidirectionalStreamingEchoTest() = runTest( + methodName = "BidirectionalStreamingEcho", + type = MethodType.BIDI_STREAMING, + ) { channel, descriptor -> + val response = bidirectionalStreamingRpc(channel, descriptor, flow { + repeat(5) { + emit(EchoRequest { message = "Eccchhooo" }) + } + }) + + var i = 0 + response.collect { + i++ + assertEquals("Eccchhooo", it.message) + } + assertEquals(5, i) + } + fun runTest( methodName: String, type: MethodType, 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 ba5d0bc64..b3aac0fd6 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 @@ -15,16 +15,36 @@ import platform.posix.memset import kotlin.experimental.ExperimentalNativeApi import kotlin.native.ref.createCleaner +/** + * The result of a batch operation (see [CompletionQueue.runBatch]). + */ internal sealed interface BatchResult { + /** + * Happens when a batch was submitted and... + * - the queue is closed + * - the queue is in the process of a force shutdown + * - the queue is in the process of a normal shutdown, and the batch is a new `RECV_STATUS_ON_CLIENT` batch. + */ object CQShutdown : BatchResult + + /** + * Happens when the batch couldn't be submitted for some reason. + */ data class CallError(val error: grpc_call_error) : BatchResult + + /** + * Happens when the batch was successfully submitted. + * The [future] will be completed with `true` if the batch was successful, `false` otherwise. + * In the case of `false`, the status of the `RECV_STATUS_ON_CLIENT` batch will provide the error details. + */ data class Called(val future: CallbackFuture) : BatchResult } /** - * A coroutine wrapper around the grpc completion_queue, which manages message operations. + * The Kotlin wrapper for the native grpc_completion_queue. * It is based on the "new" callback API; therefore, there are no kotlin-side threads required to poll * the queue. + * Users can attach to the returned [CallbackFuture] if the batch was successfully submitted (see [BatchResult]). */ internal class CompletionQueue { @@ -44,11 +64,13 @@ internal class CompletionQueue { @Suppress("PropertyName") internal val _shutdownDone = CallbackFuture() - // used for spinning lock. false means not used (available) + // used to synchronize the start of a new batch operation. private val batchStartGuard = SynchronizedObject() + // a stable reference of this used as user_data in the shutdown callback. private val thisStableRef = StableRef.create(this) + // the shutdown functor/tag called when the queue is shut down. private val shutdownFunctor = nativeHeap.alloc { functor.functor_run = SHUTDOWN_CB user_data = thisStableRef.asCPointer() @@ -69,6 +91,10 @@ internal class CompletionQueue { require(kgrpc_iomgr_run_in_background()) { "The gRPC iomgr is not running background threads, required for callback based APIs." } } + /** + * Submits a batch operation to the queue. + * See [BatchResult] for possible outcomes. + */ fun runBatch(call: NativeClientCall<*, *>, ops: CPointer, nOps: ULong): BatchResult { val completion = CallbackFuture() val tag = newCbTag(completion, OPS_COMPLETE_CB) @@ -102,7 +128,11 @@ internal class CompletionQueue { return BatchResult.Called(completion) } - // must not be canceled as it cleans resources and sets the state to CLOSED + /** + * Shuts down the queue. + * The method returns immediately, but the queue will be shut down asynchronously. + * The returned [CallbackFuture] will be completed with `Unit` when the queue is shut down. + */ fun shutdown(force: Boolean = false): CallbackFuture { if (force) { forceShutdown = true 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 3074caf6b..629d6b113 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 @@ -34,6 +34,7 @@ internal class NativeClientCall( } init { + // cancel the call if the job is canceled. callJob.invokeOnCompletion { when (it) { is CancellationException -> { @@ -63,6 +64,9 @@ internal class NativeClientCall( // 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) + // we currently don't buffer messages, so after one `sendMessage` call, ready turns false. + private val ready = atomic(true) + /** * Increments the [inFlight] counter by one. * This should be called before starting a batch. @@ -113,6 +117,16 @@ internal class NativeClientCall( } } + /** + * Sets the [ready] flag to true and calls the listener's onReady callback. + * This is called as soon as the RECV_MESSAGE batch is finished (or failed). + */ + private fun turnReady() { + if (ready.compareAndSet(expect = false, update = true)) { + listener?.onReady() + } + } + override fun start( responseListener: Listener, @@ -124,13 +138,19 @@ internal class NativeClientCall( listener = responseListener // start receiving the status from the completion queue, - // which is bound to the lifecycle of the call. - val success = initializeCallOnCQ() + // which is bound to the lifetime of the call. + val success = startRecvStatus() if (!success) return + // send and receive initial headers to/from the server sendAndReceiveInitialMetadata() } + /** + * Submits a batch operation to the [CompletionQueue] and handle the returned [BatchResult]. + * If the batch was successfully submitted, [onSuccess] is called. + * In any case, [cleanup] is called. + */ private fun runBatch( ops: CPointer, nOps: ULong, @@ -175,12 +195,19 @@ internal class NativeClientCall( } } + /** + * Starts a batch operation to receive the status from the completion queue. + * This operation is bound to the lifetime of the call, so it will finish once all other operations are done. + * If this operation fails, it will call [markClosePending] with the corresponding error, as the entire call + * si considered failed. + * + * @return true if the batch was successfully submitted, false otherwise. + * In this case, the call is considered failed. + */ @OptIn(ExperimentalStdlibApi::class) - private fun initializeCallOnCQ(): Boolean { + private fun startRecvStatus(): Boolean { checkNotNull(listener) { "Not yet started" } val arena = Arena() - // this must not be canceled as it sets the call status. - // if the client itself got canceled, this will return fast. val statusCode = arena.alloc() val statusDetails = arena.alloc() val errorStr = arena.alloc>() @@ -253,12 +280,19 @@ internal class NativeClientCall( } } + /** + * Requests [numMessages] messages from the server. + * This must only be called again after [numMessages] were received in the [Listener.onMessage] callback. + */ override fun request(numMessages: Int) { check(numMessages > 0) { "numMessages must be > 0" } val listener = checkNotNull(listener) { "Not yet started" } check(!cancelled) { "Already cancelled" } var remainingMessages = numMessages + + // we need to request only one message at a time, so we use a recursive function that + // requests one message and then calls itself again. fun post() { if (remainingMessages-- <= 0) return @@ -272,13 +306,16 @@ internal class NativeClientCall( if (recvPtr.value != null) grpc_byte_buffer_destroy(recvPtr.value) arena.clear() }) { - val buf = recvPtr.value ?: return@runBatch // EOS + // if the call was successful, but no message was received, we reached the end-of-stream. + val buf = recvPtr.value ?: return@runBatch val msg = methodDescriptor.getResponseMarshaller() .parse(buf.toKotlin().asInputStream()) listener.onMessage(msg) - post() // post next only now + post() } } + + // start requesting messages post() } @@ -310,19 +347,31 @@ internal class NativeClientCall( } } + override fun isReady(): Boolean = ready.value + override fun sendMessage(message: Request) { checkNotNull(listener) { "Not yet started" } check(!halfClosed) { "Already half closed." } check(!cancelled) { "Already cancelled." } + check(isReady()) { "Not yet ready." } + + // set ready false, as only one message can be sent at a time. + ready.value = false val arena = Arena() val inputStream = methodDescriptor.getRequestMarshaller().stream(message) val byteBuffer = inputStream.asSource().toGrpcByteBuffer() + val op = arena.alloc { op = GRPC_OP_SEND_MESSAGE data.send_message.send_message = byteBuffer } + runBatch(op.ptr, 1u, cleanup = { + // no mater what happens, we need to set ready to true again. + turnReady() + + // actual cleanup grpc_byte_buffer_destroy(byteBuffer) arena.clear() }) { diff --git a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeManagedChannel.kt b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeManagedChannel.kt index ceea6e4ec..6ef126dcb 100644 --- a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeManagedChannel.kt +++ b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeManagedChannel.kt @@ -19,6 +19,9 @@ import kotlin.experimental.ExperimentalNativeApi import kotlin.native.ref.createCleaner import kotlin.time.Duration +/** + * Wrapper for [grpc_channel_credentials]. + */ internal sealed class GrpcCredentials( internal val raw: CPointer, ) { @@ -27,12 +30,21 @@ internal sealed class GrpcCredentials( } } +/** + * Insecure credentials. + */ internal class GrpcInsecureCredentials() : GrpcCredentials(grpc_insecure_credentials_create() ?: error("Failed to create credentials")) - +// default propagation mask for all calls. private const val GRPC_PROPAGATE_DEFAULTS = 0x0000FFFFu +/** + * Native implementation of [ManagedChannel]. + * + * @param target The target address to connect to. + * @param credentials The credentials to use for the connection. + */ internal class NativeManagedChannel( target: String, // we must store them, otherwise the credentials are getting released @@ -89,11 +101,14 @@ internal class NativeManagedChannel( return } if (force) { + // cancel all jobs, such that the shutdown is completing faster (not immediate). // TODO: replace jobs by custom pendingCallClass. callJobSupervisor.cancelChildren(CancellationException("Channel is shutting down")) } // wait for the completion queue to shut down. + // the completion queue will be shut down after all requests are completed. + // therefore, we don't have to wait for the callJobs to be completed. cq.shutdown(force).onComplete { if (isTerminatedInternal.complete(Unit)) { // release the grpc runtime, so it might call grpc_shutdown() From 1b6c73d5b0bcffa87cf99dd07197774c32d76857 Mon Sep 17 00:00:00 2001 From: Johannes Zottele Date: Tue, 19 Aug 2025 17:20:16 +0200 Subject: [PATCH 13/14] grpc-native: Address PR comments Signed-off-by: Johannes Zottele --- .../kotlinx/rpc/grpc/internal/ClientCall.kt | 4 ++ .../kotlinx/rpc/grpc/test/RawClientTest.kt | 6 +- .../src/nativeInterop/cinterop/libkgrpc.def | 4 +- .../kotlinx/rpc/grpc/ManagedChannel.native.kt | 2 +- .../rpc/grpc/internal/CallbackFuture.kt | 6 +- .../rpc/grpc/internal/CompletionQueue.kt | 16 +++-- .../rpc/grpc/internal/NativeClientCall.kt | 64 +++++++++---------- .../rpc/grpc/internal/NativeGrpcLibrary.kt | 7 +- .../rpc/grpc/internal/NativeManagedChannel.kt | 23 ++++--- .../kotlin/kotlinx/rpc/grpc/internal/utils.kt | 13 ++-- protobuf/protobuf-core/build.gradle.kts | 1 - 11 files changed, 74 insertions(+), 72 deletions(-) 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 e7128a5cc..2c3146bd2 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 @@ -22,6 +22,10 @@ import kotlinx.rpc.internal.utils.InternalRpcApi * Internally, it buffers the messages. * - On Native, you can only call [sendMessage] when [isReady] returns true. There is no buffering; therefore, * only one message can be sent at a time. + * + * Request message number: + * - On JVM, there is no limit on the number of messages you can [request]. + * - On Native, you can only call [request] with up to `16`. */ @InternalRpcApi public expect abstract class ClientCall { 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 b8bb4aed1..18743bdac 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 @@ -4,7 +4,6 @@ package kotlinx.rpc.grpc.test -import grpc.examples.echo.* import kotlinx.coroutines.delay import kotlinx.coroutines.flow.flow import kotlinx.coroutines.test.runTest @@ -17,6 +16,7 @@ import kotlin.test.assertEquals /** * Tests for JVM and Native clients. */ +// TODO: Start echo service server automatically class RawClientTest { @Test @@ -102,9 +102,5 @@ class RawClientTest { channel.shutdown() channel.awaitTermination() } - - } - - } \ No newline at end of file diff --git a/grpc/grpc-core/src/nativeInterop/cinterop/libkgrpc.def b/grpc/grpc-core/src/nativeInterop/cinterop/libkgrpc.def index 38a1c133f..59aa61bcd 100644 --- a/grpc/grpc-core/src/nativeInterop/cinterop/libkgrpc.def +++ b/grpc/grpc-core/src/nativeInterop/cinterop/libkgrpc.def @@ -1,9 +1,9 @@ headers = kgrpc.h grpc/grpc.h grpc/credentials.h grpc/byte_buffer_reader.h \ - grpc/support/alloc.h + grpc/support/alloc.h grpc/impl/propagation_bits.h headerFilter= kgrpc.h grpc/slice.h grpc/byte_buffer.h grpc/grpc.h \ grpc/impl/grpc_types.h grpc/credentials.h grpc/support/time.h grpc/byte_buffer_reader.h \ - grpc/support/alloc.h + grpc/support/alloc.h grpc/impl/propagation_bits.h noStringConversion = grpc_slice_from_copied_buffer my_grpc_slice_from_copied_buffer strictEnums = grpc_status_code grpc_connectivity_state grpc_call_error 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 5c6c40389..e1a55a904 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 @@ -21,7 +21,7 @@ public actual abstract class ManagedChannelPlatform : GrpcChannel() */ public actual abstract class ManagedChannelBuilder> { public actual open fun usePlaintext(): T { - error("Builder does not override usePlaintext()") + error("Builder does not support usePlaintext()") } } diff --git a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/CallbackFuture.kt b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/CallbackFuture.kt index ae486a7f0..6dbdb5b74 100644 --- a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/CallbackFuture.kt +++ b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/CallbackFuture.kt @@ -11,7 +11,7 @@ import kotlinx.atomicfu.atomic */ internal class CallbackFuture { private sealed interface State { - data class Pending(val cbs: List<(T) -> Unit> = emptyList()) : State + data class Pending(val callbacks: List<(T) -> Unit> = emptyList()) : State data class Done(val value: T) : State } @@ -22,7 +22,7 @@ internal class CallbackFuture { while (true) { when (val s = state.value) { is State.Pending -> if (state.compareAndSet(s, State.Done(result))) { - toInvoke = s.cbs + toInvoke = s.callbacks break } @@ -40,7 +40,7 @@ internal class CallbackFuture { } is State.Pending -> { - val next = State.Pending(s.cbs + callback) // copy-on-write append + val next = State.Pending(s.callbacks + callback) // copy-on-write append if (state.compareAndSet(s, next)) return } } 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 b3aac0fd6..418c94601 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 @@ -30,18 +30,18 @@ internal sealed interface BatchResult { /** * Happens when the batch couldn't be submitted for some reason. */ - data class CallError(val error: grpc_call_error) : BatchResult + data class SubmitError(val error: grpc_call_error) : BatchResult /** * Happens when the batch was successfully submitted. * The [future] will be completed with `true` if the batch was successful, `false` otherwise. * In the case of `false`, the status of the `RECV_STATUS_ON_CLIENT` batch will provide the error details. */ - data class Called(val future: CallbackFuture) : BatchResult + data class Submitted(val future: CallbackFuture) : BatchResult } /** - * The Kotlin wrapper for the native grpc_completion_queue. + * A thread-safe Kotlin wrapper for the native grpc_completion_queue. * It is based on the "new" callback API; therefore, there are no kotlin-side threads required to poll * the queue. * Users can attach to the returned [CallbackFuture] if the batch was successfully submitted (see [BatchResult]). @@ -50,7 +50,7 @@ internal class CompletionQueue { internal enum class State { OPEN, SHUTTING_DOWN, CLOSED } - // if the queue was called with forceShutdown = true, + // if the shutdown() was called with forceShutdown = true, // it will reject all new batches and wait for all current ones to finish. private var forceShutdown = false @@ -122,16 +122,20 @@ internal class CompletionQueue { if (err != grpc_call_error.GRPC_CALL_OK) { // if the call was not successful, the callback will not be invoked. deleteCbTag(tag) - return BatchResult.CallError(err) + return BatchResult.SubmitError(err) } - return BatchResult.Called(completion) + return BatchResult.Submitted(completion) } /** * Shuts down the queue. * The method returns immediately, but the queue will be shut down asynchronously. * The returned [CallbackFuture] will be completed with `Unit` when the queue is shut down. + * + * @param force if `true`, the queue will reject all new batches with [BatchResult.CQShutdown]. + * Otherwise, the queue allows submitting new batches and shutdown only when there are no more + * ongoing batches. */ fun shutdown(force: Boolean = false): CallbackFuture { if (force) { 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 629d6b113..0cf37653a 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 @@ -54,7 +54,7 @@ internal class NativeClientCall( private var closed = atomic(false) // tracks how many operations are in flight (not yet completed by the listener). - // if 0, there are no more operations (except for the RECV_STATUS_ON_CLIENT op). + // if 0 and we got a closeInfo (containing the status), there are no more ongoing operations. // in this case, we can safely call onClose on the listener. // we need this mechanism to ensure that onClose is not called while any other callback is still running // on the listener. @@ -64,7 +64,7 @@ internal class NativeClientCall( // 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) - // we currently don't buffer messages, so after one `sendMessage` call, ready turns false. + // we currently don't buffer messages, so after one `sendMessage` call, ready turns false. (KRPC-192) private val ready = atomic(true) /** @@ -81,11 +81,11 @@ internal class NativeClientCall( * AND the corresponding listener callback returned. * * If the counter reaches 0, no more listener callbacks are executed, and the call can be closed by - * calling [tryDeliverClose]. + * calling [tryToCloseCall]. */ private fun endOp() { if (inFlight.decrementAndGet() == 0) { - tryDeliverClose() + tryToCloseCall() } } @@ -97,23 +97,23 @@ internal class NativeClientCall( * - If the [inFlight] counter is not 0, this does nothing. * - Otherwise, the listener's onClose callback is invoked and the call is closed. */ - private fun tryDeliverClose() { - val s = closeInfo.value ?: return + private fun tryToCloseCall() { + val info = closeInfo.value ?: return if (inFlight.value == 0 && closed.compareAndSet(expect = false, update = true)) { - val lst = checkNotNull(listener) { "Not yet started" } + val lst = checkNotNull(listener) { internalError("Not yet started") } // allows the managed channel to join for the call to finish. callJob.complete() - lst.onClose(s.first, s.second) + lst.onClose(info.first, info.second) } } /** - * Sets the [closeInfo] and calls [tryDeliverClose]. - * This is called as soon as the RECV_STATUS_ON_CLIENT batch is finished. + * 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) { if (closeInfo.compareAndSet(null, Pair(status, trailers))) { - tryDeliverClose() + tryToCloseCall() } } @@ -132,8 +132,8 @@ internal class NativeClientCall( responseListener: Listener, headers: GrpcTrailers, ) { - check(listener == null) { "Already started" } - check(!cancelled) { "Already cancelled." } + check(listener == null) { internalError("Already started") } + check(!cancelled) { internalError("Already cancelled.") } listener = responseListener @@ -164,7 +164,7 @@ internal class NativeClientCall( beginOp() when (val callResult = cq.runBatch(this@NativeClientCall, ops, nOps)) { - is BatchResult.Called -> { + is BatchResult.Submitted -> { callResult.future.onComplete { success -> try { if (success) { @@ -184,7 +184,7 @@ internal class NativeClientCall( cancelInternal(grpc_status_code.GRPC_STATUS_UNAVAILABLE, "Channel shutdown") } - is BatchResult.CallError -> { + is BatchResult.SubmitError -> { cleanup() endOp() cancelInternal( @@ -196,7 +196,7 @@ internal class NativeClientCall( } /** - * Starts a batch operation to receive the status from the completion queue. + * Starts a batch operation to receive the status from the completion queue (RECV_STATUS_ON_CLIENT). * This operation is bound to the lifetime of the call, so it will finish once all other operations are done. * If this operation fails, it will call [markClosePending] with the corresponding error, as the entire call * si considered failed. @@ -206,7 +206,7 @@ internal class NativeClientCall( */ @OptIn(ExperimentalStdlibApi::class) private fun startRecvStatus(): Boolean { - checkNotNull(listener) { "Not yet started" } + checkNotNull(listener) { internalError("Not yet started") } val arena = Arena() val statusCode = arena.alloc() val statusDetails = arena.alloc() @@ -221,7 +221,7 @@ internal class NativeClientCall( } when (val callResult = cq.runBatch(this@NativeClientCall, op.ptr, 1u)) { - is BatchResult.Called -> { + is BatchResult.Submitted -> { callResult.future.onComplete { val details = statusDetails.toByteArray().toKString() val status = Status(statusCode.value.toKotlin(), details, null) @@ -244,7 +244,7 @@ internal class NativeClientCall( return false } - is BatchResult.CallError -> { + is BatchResult.SubmitError -> { arena.clear() markClosePending( Status(StatusCode.INTERNAL, "Failed to start call: ${callResult.error}"), @@ -285,9 +285,11 @@ internal class NativeClientCall( * This must only be called again after [numMessages] were received in the [Listener.onMessage] callback. */ override fun request(numMessages: Int) { - check(numMessages > 0) { "numMessages must be > 0" } - val listener = checkNotNull(listener) { "Not yet started" } - check(!cancelled) { "Already cancelled" } + check(numMessages > 0) { internalError("numMessages must be > 0") } + // 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") } var remainingMessages = numMessages @@ -333,8 +335,8 @@ internal class NativeClientCall( } override fun halfClose() { - check(!halfClosed) { "Already half closed." } - check(!cancelled) { "Already cancelled." } + check(!halfClosed) { internalError("Already half closed.") } + check(!cancelled) { internalError("Already cancelled.") } halfClosed = true val arena = Arena() @@ -350,10 +352,10 @@ internal class NativeClientCall( override fun isReady(): Boolean = ready.value override fun sendMessage(message: Request) { - checkNotNull(listener) { "Not yet started" } - check(!halfClosed) { "Already half closed." } - check(!cancelled) { "Already cancelled." } - check(isReady()) { "Not yet ready." } + checkNotNull(listener) { internalError("Not yet started") } + check(!halfClosed) { internalError("Already half closed.") } + check(!cancelled) { internalError("Already cancelled.") } + check(isReady()) { internalError("Not yet ready.") } // set ready false, as only one message can be sent at a time. ready.value = false @@ -368,14 +370,12 @@ internal class NativeClientCall( } runBatch(op.ptr, 1u, cleanup = { - // no mater what happens, we need to set ready to true again. - turnReady() - // actual cleanup grpc_byte_buffer_destroy(byteBuffer) arena.clear() }) { - // Nothing to do here + // set ready true, as we can now send another message. + turnReady() } } } diff --git a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeGrpcLibrary.kt b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeGrpcLibrary.kt index 98c0aeac5..615295bec 100644 --- a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeGrpcLibrary.kt +++ b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeGrpcLibrary.kt @@ -21,17 +21,16 @@ internal object GrpcRuntime { /** Acquire a runtime reference. Must be closed exactly once. */ fun acquire(): AutoCloseable { refLock.withLock { - val prev = 0 - refs++ + val prev = refs++ if (prev == 0) grpc_init() } return object : AutoCloseable { - private var done = atomic(false) + private val done = atomic(false) override fun close() { if (!done.compareAndSet(expect = false, update = true)) return refLock.withLock { val now = --refs - require(now >= 0) { "release() without matching acquire()" } + require(now >= 0) { internalError("release() without matching acquire()") } if (now == 0) { grpc_shutdown() } diff --git a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeManagedChannel.kt b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeManagedChannel.kt index 6ef126dcb..3f03b92f1 100644 --- a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeManagedChannel.kt +++ b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/NativeManagedChannel.kt @@ -8,6 +8,7 @@ package kotlinx.rpc.grpc.internal import cnames.structs.grpc_channel import cnames.structs.grpc_channel_credentials +import kotlinx.atomicfu.atomic import kotlinx.cinterop.CPointer import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.coroutines.* @@ -36,8 +37,6 @@ internal sealed class GrpcCredentials( internal class GrpcInsecureCredentials() : GrpcCredentials(grpc_insecure_credentials_create() ?: error("Failed to create credentials")) -// default propagation mask for all calls. -private const val GRPC_PROPAGATE_DEFAULTS = 0x0000FFFFu /** * Native implementation of [ManagedChannel]. @@ -72,9 +71,9 @@ internal class NativeManagedChannel( override val platformApi: ManagedChannelPlatform = this - private var isShutdownInternal: Boolean = false - override val isShutdown: Boolean = isShutdownInternal - private var isTerminatedInternal = CompletableDeferred() + private var isShutdownInternal = atomic(false) + override val isShutdown: Boolean = isShutdownInternal.value + private val isTerminatedInternal = CompletableDeferred() override val isTerminated: Boolean get() = isTerminatedInternal.isCompleted @@ -96,7 +95,7 @@ internal class NativeManagedChannel( } private fun shutdownInternal(force: Boolean) { - isShutdownInternal = true + isShutdownInternal.value = true if (isTerminatedInternal.isCompleted) { return } @@ -121,7 +120,7 @@ internal class NativeManagedChannel( methodDescriptor: MethodDescriptor, callOptions: GrpcCallOptions, ): ClientCall { - check(!isShutdown) { "Channel is shutdown" } + check(!isShutdown) { internalError("Channel is shutdown") } val callJob = Job(callJobSupervisor) @@ -130,8 +129,14 @@ internal class NativeManagedChannel( // the user does not do this to align it with the java implementation. val methodNameSlice = "/$methodFullName".toGrpcSlice() val rawCall = grpc_channel_create_call( - channel = raw, parent_call = null, propagation_mask = GRPC_PROPAGATE_DEFAULTS, completion_queue = cq.raw, - method = methodNameSlice, host = null, deadline = gpr_inf_future(GPR_CLOCK_REALTIME), reserved = null + channel = raw, + parent_call = null, + propagation_mask = GRPC_PROPAGATE_DEFAULTS, + completion_queue = cq.raw, + method = methodNameSlice, + host = null, + deadline = gpr_inf_future(GPR_CLOCK_REALTIME), + reserved = null ) ?: error("Failed to create call") return NativeClientCall( 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 04bb5fcbf..8911e582f 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 @@ -13,6 +13,10 @@ import kotlinx.rpc.grpc.StatusCode import libkgrpc.* import platform.posix.memcpy +internal fun internalError(message: String) { + error("Unexpected internal error: $message. Please, report the issue here: https://github.com/Kotlin/kotlinx-rpc/issues/new?template=bug_report.md") +} + @OptIn(ExperimentalForeignApi::class, InternalIoApi::class, UnsafeIoApi::class) internal fun Sink.writeFully(buffer: CPointer, offset: Long, length: Long) { var consumed = 0L @@ -30,15 +34,6 @@ internal fun Sink.writeFully(buffer: CPointer, offset: Long, length: Lo } } -internal suspend fun withArena(block: suspend (Arena) -> Unit) = - Arena().let { arena -> - try { - block(arena) - } finally { - arena.clear() - } - } - internal fun grpc_slice.toByteArray(): ByteArray = memScoped { val out = ByteArray(len().toInt()) if (out.isEmpty()) return out diff --git a/protobuf/protobuf-core/build.gradle.kts b/protobuf/protobuf-core/build.gradle.kts index 203a40632..2c71d5306 100644 --- a/protobuf/protobuf-core/build.gradle.kts +++ b/protobuf/protobuf-core/build.gradle.kts @@ -28,7 +28,6 @@ kotlin { api(projects.utils) api(projects.protobuf.protobufInputStream) api(projects.grpc.grpcCodec) - implementation("com.google.api.grpc:proto-google-common-protos:2.60.0") api(libs.kotlinx.io.core) } From 10f4c7bde0bedf91e98200d707ee67d5e3d6f4f6 Mon Sep 17 00:00:00 2001 From: Johannes Zottele Date: Tue, 19 Aug 2025 18:16:51 +0200 Subject: [PATCH 14/14] grpc-native: Address PR comments Signed-off-by: Johannes Zottele --- .../kotlin/kotlinx/rpc/grpc/ManagedChannel.native.kt | 8 ++------ .../kotlin/kotlinx/rpc/grpc/internal/CompletionQueue.kt | 6 +++--- .../nativeMain/kotlin/kotlinx/rpc/grpc/internal/utils.kt | 2 +- 3 files changed, 6 insertions(+), 10 deletions(-) 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 e1a55a904..be43d6afc 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 @@ -6,10 +6,7 @@ package kotlinx.rpc.grpc -import kotlinx.rpc.grpc.internal.GrpcChannel -import kotlinx.rpc.grpc.internal.GrpcCredentials -import kotlinx.rpc.grpc.internal.GrpcInsecureCredentials -import kotlinx.rpc.grpc.internal.NativeManagedChannel +import kotlinx.rpc.grpc.internal.* /** * Same as [ManagedChannel], but is platform-exposed. @@ -31,7 +28,6 @@ internal class NativeManagedChannelBuilder( private var credentials: GrpcCredentials? = null override fun usePlaintext(): NativeManagedChannelBuilder { - check(credentials == null) { "Credentials already set" } credentials = GrpcInsecureCredentials() return this } @@ -46,7 +42,7 @@ internal class NativeManagedChannelBuilder( } internal actual fun ManagedChannelBuilder<*>.buildChannel(): ManagedChannel { - check(this is NativeManagedChannelBuilder) { "Wrong builder type, expected NativeManagedChannelBuilder" } + check(this is NativeManagedChannelBuilder) { internalError("Wrong builder type, expected NativeManagedChannelBuilder") } return buildChannel() } 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 418c94601..157428ccd 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 @@ -52,7 +52,7 @@ internal class CompletionQueue { // if the shutdown() was called with forceShutdown = true, // it will reject all new batches and wait for all current ones to finish. - private var forceShutdown = false + private val forceShutdown = atomic(false) // internal as it must be accessible from the SHUTDOWN_CB, // but it shouldn't be used from outside this file. @@ -109,7 +109,7 @@ internal class CompletionQueue { return BatchResult.CQShutdown } - if (forceShutdown || _state.value == State.CLOSED) { + if (forceShutdown.value || _state.value == State.CLOSED) { // if the queue is either closed or in the process of a FORCE shutdown, // new batches will instantly fail. deleteCbTag(tag) @@ -139,7 +139,7 @@ internal class CompletionQueue { */ fun shutdown(force: Boolean = false): CallbackFuture { if (force) { - forceShutdown = true + forceShutdown.value = true } if (!_state.compareAndSet(State.OPEN, State.SHUTTING_DOWN)) { // the first call to shutdown() makes transition and to SHUTTING_DOWN and 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 8911e582f..69bc3b1ee 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 @@ -47,7 +47,7 @@ internal fun grpc_slice.toByteArray(): ByteArray = memScoped { internal fun CPointer.toKotlin(): Buffer = memScoped { val reader = alloc() check(grpc_byte_buffer_reader_init(reader.ptr, this@toKotlin) == 1) - { "Failed to initialized byte buffer." } + { internalError("Failed to initialized byte buffer.") } val out = Buffer() val slice = alloc()