diff --git a/build.gradle.kts b/build.gradle.kts
index 516f8a6fe..575dbbf0d 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -8,7 +8,6 @@ plugins {
alias(libs.plugins.serialization) apply false
alias(libs.plugins.kotlinx.rpc) apply false
alias(libs.plugins.atomicfu) apply false
- alias(libs.plugins.conventions.kover)
alias(libs.plugins.conventions.root)
}
diff --git a/detekt/config.yaml b/detekt/config.yaml
index 2553f4594..1821e9d5d 100644
--- a/detekt/config.yaml
+++ b/detekt/config.yaml
@@ -219,7 +219,7 @@ empty-blocks:
EmptyForBlock:
active: true
EmptyFunctionBlock:
- active: true
+ active: false
ignoreOverridden: false
EmptyIfBlock:
active: true
@@ -288,7 +288,7 @@ exceptions:
ThrowingNewInstanceOfSameException:
active: true
TooGenericExceptionCaught:
- active: true
+ active: false
excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**']
exceptionNames:
- 'ArrayIndexOutOfBoundsException'
diff --git a/docs/pages/kotlinx-rpc/topics/platforms.topic b/docs/pages/kotlinx-rpc/topics/platforms.topic
index 8470157bc..9b6cd14c8 100644
--- a/docs/pages/kotlinx-rpc/topics/platforms.topic
+++ b/docs/pages/kotlinx-rpc/topics/platforms.topic
@@ -73,7 +73,7 @@
core |
jvm |
browsernode |
-wasmJsbrowserd8node wasmWasinode |
+wasmJsbrowsernode wasmWasinode |
appleiosiosArm64iosSimulatorArm64iosX64 macosmacosArm64macosX64 watchoswatchosArm32watchosArm64watchosDeviceArm64watchosSimulatorArm64watchosX64 tvostvosArm64tvosSimulatorArm64tvosX64 linuxlinuxArm64linuxX64 windowsmingwX64 |
@@ -81,7 +81,7 @@
utils |
jvm |
browsernode |
-wasmJsbrowserd8node wasmWasinode |
+wasmJsbrowsernode wasmWasinode |
appleiosiosArm64iosSimulatorArm64iosX64 macosmacosArm64macosX64 watchoswatchosArm32watchosArm64watchosDeviceArm64watchosSimulatorArm64watchosX64 tvostvosArm64tvosSimulatorArm64tvosX64 linuxlinuxArm64linuxX64 windowsmingwX64 |
@@ -89,7 +89,7 @@
krpc-client |
jvm |
browsernode |
-wasmJsbrowserd8node |
+wasmJsbrowsernode |
appleiosiosArm64iosSimulatorArm64iosX64 macosmacosArm64macosX64 watchoswatchosArm64watchosSimulatorArm64watchosX64 tvostvosArm64tvosSimulatorArm64tvosX64 linuxlinuxArm64linuxX64 windowsmingwX64 |
@@ -97,7 +97,7 @@
krpc-core |
jvm |
browsernode |
-wasmJsbrowserd8node |
+wasmJsbrowsernode |
appleiosiosArm64iosSimulatorArm64iosX64 macosmacosArm64macosX64 watchoswatchosArm64watchosSimulatorArm64watchosX64 tvostvosArm64tvosSimulatorArm64tvosX64 linuxlinuxArm64linuxX64 windowsmingwX64 |
@@ -105,7 +105,7 @@
krpc-logging |
jvm |
browsernode |
-wasmJsbrowserd8node |
+wasmJsbrowsernode |
appleiosiosArm64iosSimulatorArm64iosX64 macosmacosArm64macosX64 watchoswatchosArm64watchosSimulatorArm64watchosX64 tvostvosArm64tvosSimulatorArm64tvosX64 linuxlinuxArm64linuxX64 windowsmingwX64 |
@@ -113,7 +113,7 @@
krpc-server |
jvm |
browsernode |
-wasmJsbrowserd8node |
+wasmJsbrowsernode |
appleiosiosArm64iosSimulatorArm64iosX64 macosmacosArm64macosX64 watchoswatchosArm64watchosSimulatorArm64watchosX64 tvostvosArm64tvosSimulatorArm64tvosX64 linuxlinuxArm64linuxX64 windowsmingwX64 |
@@ -129,7 +129,7 @@
krpc-ktor-client |
jvm |
browsernode |
-wasmJsbrowserd8node |
+wasmJsbrowsernode |
appleiosiosArm64iosSimulatorArm64iosX64 macosmacosArm64macosX64 watchoswatchosArm64watchosSimulatorArm64watchosX64 tvostvosArm64tvosSimulatorArm64tvosX64 linuxlinuxArm64linuxX64 windowsmingwX64 |
@@ -137,7 +137,7 @@
krpc-ktor-core |
jvm |
browsernode |
-wasmJsbrowserd8node |
+wasmJsbrowsernode |
appleiosiosArm64iosSimulatorArm64iosX64 macosmacosArm64macosX64 watchoswatchosArm64watchosSimulatorArm64watchosX64 tvostvosArm64tvosSimulatorArm64tvosX64 linuxlinuxArm64linuxX64 windowsmingwX64 |
@@ -145,7 +145,7 @@
krpc-ktor-server |
jvm |
browsernode |
-wasmJsbrowserd8node |
+wasmJsbrowsernode |
appleiosiosArm64iosSimulatorArm64iosX64 macosmacosArm64macosX64 watchoswatchosArm64watchosSimulatorArm64watchosX64 tvostvosArm64tvosSimulatorArm64tvosX64 linuxlinuxArm64linuxX64 windowsmingwX64 |
@@ -153,7 +153,7 @@
krpc-serialization-cbor |
jvm |
browsernode |
-wasmJsbrowserd8node |
+wasmJsbrowsernode |
appleiosiosArm64iosSimulatorArm64iosX64 macosmacosArm64macosX64 watchoswatchosArm64watchosSimulatorArm64watchosX64 tvostvosArm64tvosSimulatorArm64tvosX64 linuxlinuxArm64linuxX64 windowsmingwX64 |
@@ -161,7 +161,7 @@
krpc-serialization-core |
jvm |
browsernode |
-wasmJsbrowserd8node |
+wasmJsbrowsernode |
appleiosiosArm64iosSimulatorArm64iosX64 macosmacosArm64macosX64 watchoswatchosArm64watchosSimulatorArm64watchosX64 tvostvosArm64tvosSimulatorArm64tvosX64 linuxlinuxArm64linuxX64 windowsmingwX64 |
@@ -169,7 +169,7 @@
krpc-serialization-json |
jvm |
browsernode |
-wasmJsbrowserd8node |
+wasmJsbrowsernode |
appleiosiosArm64iosSimulatorArm64iosX64 macosmacosArm64macosX64 watchoswatchosArm64watchosSimulatorArm64watchosX64 tvostvosArm64tvosSimulatorArm64tvosX64 linuxlinuxArm64linuxX64 windowsmingwX64 |
@@ -177,7 +177,7 @@
krpc-serialization-protobuf |
jvm |
browsernode |
-wasmJsbrowserd8node |
+wasmJsbrowsernode |
appleiosiosArm64iosSimulatorArm64iosX64 macosmacosArm64macosX64 watchoswatchosArm64watchosSimulatorArm64watchosX64 tvostvosArm64tvosSimulatorArm64tvosX64 linuxlinuxArm64linuxX64 windowsmingwX64 |
diff --git a/gradle-conventions/src/main/kotlin/conventions-common.gradle.kts b/gradle-conventions/src/main/kotlin/conventions-common.gradle.kts
index 42638476a..78b01a3fc 100644
--- a/gradle-conventions/src/main/kotlin/conventions-common.gradle.kts
+++ b/gradle-conventions/src/main/kotlin/conventions-common.gradle.kts
@@ -51,11 +51,3 @@ afterEvaluate {
})
}
}
-
-apply(plugin = "org.jetbrains.kotlinx.kover")
-
-val thisProject = project
-
-rootProject.configurations.matching { it.name == "kover" }.all {
- rootProject.dependencies.add("kover", thisProject)
-}
diff --git a/gradle-conventions/src/main/kotlin/util/apiValidation.kt b/gradle-conventions/src/main/kotlin/util/apiValidation.kt
index 5bbf2f661..83b56a064 100644
--- a/gradle-conventions/src/main/kotlin/util/apiValidation.kt
+++ b/gradle-conventions/src/main/kotlin/util/apiValidation.kt
@@ -11,6 +11,7 @@ import org.jetbrains.kotlin.gradle.dsl.abi.ExperimentalAbiValidation
private val excludedProjects = setOf(
"krpc-test",
"krpc-compatibility-tests",
+ "krpc-protocol-compatibility-tests",
"compiler-plugin-tests",
)
diff --git a/gradle-conventions/src/main/kotlin/util/kover.kt b/gradle-conventions/src/main/kotlin/util/kover.kt
new file mode 100644
index 000000000..ec0d7b5ee
--- /dev/null
+++ b/gradle-conventions/src/main/kotlin/util/kover.kt
@@ -0,0 +1,16 @@
+/*
+ * Copyright 2023-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
+ */
+
+package util
+
+import org.gradle.api.Project
+
+@Suppress("unused")
+fun Project.applyKover() {
+ plugins.apply("org.jetbrains.kotlinx.kover")
+
+ rootProject.configurations.matching { it.name == "kover" }.all {
+ rootProject.dependencies.add("kover", this@applyKover)
+ }
+}
diff --git a/gradle-conventions/src/main/kotlin/util/targets/wasm.kt b/gradle-conventions/src/main/kotlin/util/targets/wasm.kt
index 34b3cb171..30e52376a 100644
--- a/gradle-conventions/src/main/kotlin/util/targets/wasm.kt
+++ b/gradle-conventions/src/main/kotlin/util/targets/wasm.kt
@@ -27,7 +27,8 @@ fun KmpConfig.configureWasm() {
browser()
nodejs()
if (wasmJsD8) {
- d8()
+ // this platform needs some care KRPC-210
+// d8()
}
binaries.library()
diff --git a/kotlin-js-store/package-lock.json b/kotlin-js-store/package-lock.json
index b0ddf4130..11922e6e9 100644
--- a/kotlin-js-store/package-lock.json
+++ b/kotlin-js-store/package-lock.json
@@ -22,6 +22,8 @@
"packages/kotlinx-rpc-krpc-krpc-server-test",
"packages/kotlinx-rpc-krpc-krpc-test",
"packages/kotlinx-rpc-krpc-krpc-test-test",
+ "packages/kotlinx-rpc-tests-test-utils",
+ "packages/kotlinx-rpc-tests-test-utils-test",
"packages/kotlinx-rpc-krpc-krpc-ktor-krpc-ktor-client",
"packages/kotlinx-rpc-krpc-krpc-ktor-krpc-ktor-client-test",
"packages/kotlinx-rpc-krpc-krpc-ktor-krpc-ktor-core",
@@ -2597,6 +2599,14 @@
"resolved": "packages/kotlinx-rpc-krpc-krpc-test-test",
"link": true
},
+ "node_modules/kotlinx-rpc-tests-test-utils": {
+ "resolved": "packages/kotlinx-rpc-tests-test-utils",
+ "link": true
+ },
+ "node_modules/kotlinx-rpc-tests-test-utils-test": {
+ "resolved": "packages/kotlinx-rpc-tests-test-utils-test",
+ "link": true
+ },
"node_modules/kotlinx-rpc-utils": {
"resolved": "packages/kotlinx-rpc-utils",
"link": true
@@ -4929,6 +4939,29 @@
"webpack-cli": "6.0.1"
}
},
+ "packages/kotlinx-rpc-tests-test-utils": {
+ "version": "0.10.0-SNAPSHOT",
+ "devDependencies": {}
+ },
+ "packages/kotlinx-rpc-tests-test-utils-test": {
+ "version": "0.10.0-SNAPSHOT",
+ "dependencies": {
+ "puppeteer": "24.9.0"
+ },
+ "devDependencies": {
+ "karma": "6.4.4",
+ "karma-chrome-launcher": "3.2.0",
+ "karma-mocha": "2.0.1",
+ "karma-sourcemap-loader": "0.4.0",
+ "karma-webpack": "5.0.1",
+ "kotlin-web-helpers": "2.1.0",
+ "mocha": "11.7.1",
+ "source-map-loader": "5.0.0",
+ "source-map-support": "0.5.21",
+ "webpack": "5.100.2",
+ "webpack-cli": "6.0.1"
+ }
+ },
"packages/kotlinx-rpc-utils": {
"version": "0.10.0-SNAPSHOT",
"devDependencies": {}
diff --git a/kotlin-js-store/wasm/package-lock.json b/kotlin-js-store/wasm/package-lock.json
index 22a23110d..784ed37d2 100644
--- a/kotlin-js-store/wasm/package-lock.json
+++ b/kotlin-js-store/wasm/package-lock.json
@@ -22,6 +22,8 @@
"packages/kotlinx-rpc-krpc-krpc-server-test",
"packages/kotlinx-rpc-krpc-krpc-test",
"packages/kotlinx-rpc-krpc-krpc-test-test",
+ "packages/kotlinx-rpc-tests-test-utils",
+ "packages/kotlinx-rpc-tests-test-utils-test",
"packages/kotlinx-rpc-krpc-krpc-ktor-krpc-ktor-client",
"packages/kotlinx-rpc-krpc-krpc-ktor-krpc-ktor-client-test",
"packages/kotlinx-rpc-krpc-krpc-ktor-krpc-ktor-core",
@@ -2597,6 +2599,14 @@
"resolved": "packages/kotlinx-rpc-krpc-krpc-test-test",
"link": true
},
+ "node_modules/kotlinx-rpc-tests-test-utils": {
+ "resolved": "packages/kotlinx-rpc-tests-test-utils",
+ "link": true
+ },
+ "node_modules/kotlinx-rpc-tests-test-utils-test": {
+ "resolved": "packages/kotlinx-rpc-tests-test-utils-test",
+ "link": true
+ },
"node_modules/kotlinx-rpc-utils": {
"resolved": "packages/kotlinx-rpc-utils",
"link": true
@@ -4916,6 +4926,28 @@
"webpack-cli": "6.0.1"
}
},
+ "packages/kotlinx-rpc-tests-test-utils": {
+ "version": "0.10.0-SNAPSHOT",
+ "devDependencies": {}
+ },
+ "packages/kotlinx-rpc-tests-test-utils-test": {
+ "version": "0.10.0-SNAPSHOT",
+ "dependencies": {
+ "puppeteer": "24.9.0"
+ },
+ "devDependencies": {
+ "karma": "6.4.4",
+ "karma-chrome-launcher": "3.2.0",
+ "karma-mocha": "2.0.1",
+ "karma-sourcemap-loader": "0.4.0",
+ "karma-webpack": "5.0.1",
+ "kotlin-web-helpers": "2.1.0",
+ "mocha": "11.7.1",
+ "source-map-loader": "5.0.0",
+ "webpack": "5.100.2",
+ "webpack-cli": "6.0.1"
+ }
+ },
"packages/kotlinx-rpc-utils": {
"version": "0.10.0-SNAPSHOT",
"devDependencies": {}
diff --git a/krpc/krpc-client/src/commonMain/kotlin/kotlinx/rpc/krpc/client/KrpcClient.kt b/krpc/krpc-client/src/commonMain/kotlin/kotlinx/rpc/krpc/client/KrpcClient.kt
index 562249e6b..57c28d844 100644
--- a/krpc/krpc-client/src/commonMain/kotlin/kotlinx/rpc/krpc/client/KrpcClient.kt
+++ b/krpc/krpc-client/src/commonMain/kotlin/kotlinx/rpc/krpc/client/KrpcClient.kt
@@ -5,12 +5,27 @@
package kotlinx.rpc.krpc.client
import kotlinx.atomicfu.atomic
-import kotlinx.coroutines.*
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineName
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.DelicateCoroutinesApi
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.InternalCoroutinesApi
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.currentCoroutineContext
+import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.job
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.rpc.RpcCall
@@ -21,7 +36,8 @@ import kotlinx.rpc.descriptor.RpcInvokator
import kotlinx.rpc.internal.utils.InternalRpcApi
import kotlinx.rpc.internal.utils.getOrNull
import kotlinx.rpc.internal.utils.map.RpcInternalConcurrentHashMap
-import kotlinx.rpc.krpc.*
+import kotlinx.rpc.krpc.KrpcConfig
+import kotlinx.rpc.krpc.KrpcTransport
import kotlinx.rpc.krpc.client.internal.ClientStreamContext
import kotlinx.rpc.krpc.client.internal.ClientStreamSerializer
import kotlinx.rpc.krpc.client.internal.KrpcClientConnector
@@ -35,7 +51,6 @@ import kotlinx.serialization.StringFormat
import kotlinx.serialization.modules.SerializersModule
import kotlin.collections.first
import kotlin.concurrent.Volatile
-import kotlin.coroutines.cancellation.CancellationException
import kotlin.properties.Delegates
/**
@@ -157,7 +172,7 @@ public abstract class KrpcClient : RpcClient, KrpcEndpoint {
private val connector by lazy {
checkTransportReadiness()
- KrpcClientConnector(config.serialFormatInitializer.build(), transport, config.waitForServices)
+ KrpcClientConnector(config.serialFormatInitializer.build(), transport, config.connector)
}
private var connectionId: Long? = null
@@ -221,9 +236,11 @@ public abstract class KrpcClient : RpcClient, KrpcEndpoint {
message.errorMessage
}
- serverSupportedPlugins.completeExceptionally(
- IllegalStateException("Server failed to process protocol message: ${message.failedMessage}")
- )
+ if (!serverSupportedPlugins.isCompleted) {
+ serverSupportedPlugins.completeExceptionally(
+ IllegalStateException("Server failed to process protocol message: ${message.failedMessage}")
+ )
+ }
}
}
}
@@ -295,15 +312,15 @@ public abstract class KrpcClient : RpcClient, KrpcEndpoint {
sendCancellation(CancellationType.REQUEST, call.descriptor.fqName, callId)
- connector.unsubscribeFromMessages(call.descriptor.fqName, callId) {
- cancellingRequests.remove(callId)
- }
+ connector.unsubscribeFromMessages(call.descriptor.fqName, callId)
+ cancellingRequests.remove(callId)
}
throw e
} finally {
channel.close()
requestChannels.remove(callId)
+ connector.unsubscribeFromMessages(call.descriptor.fqName, callId)
}
}
}
@@ -341,17 +358,8 @@ public abstract class KrpcClient : RpcClient, KrpcEndpoint {
}
is KrpcCallMessage.CallException -> {
- val cause = runCatching {
- message.cause.deserialize()
- }
-
- val result = if (cause.isFailure) {
- cause.exceptionOrNull()!!
- } else {
- cause.getOrNull()!!
- }
-
- channel.close(result)
+ val cause = message.cause.deserialize()
+ channel.close(cause)
}
is KrpcCallMessage.CallSuccess, is KrpcCallMessage.StreamMessage -> {
@@ -456,8 +464,23 @@ public abstract class KrpcClient : RpcClient, KrpcEndpoint {
serviceTypeString = serviceTypeString,
)
} catch (e: CancellationException) {
+ currentCoroutineContext().ensureActive()
+
+ val wrapped = ManualCancellationException(e)
+ val serializedReason = serializeException(wrapped)
+ val message = KrpcCallMessage.StreamCancel(
+ callId = outgoingStream.callId,
+ serviceType = serviceTypeString,
+ streamId = outgoingStream.streamId,
+ cause = serializedReason,
+ connectionId = outgoingStream.connectionId,
+ serviceId = outgoingStream.serviceId,
+ )
+ sender.sendMessage(message)
+
+ // stop the flow and its coroutine, other flows are not affected
throw e
- } catch (@Suppress("detekt.TooGenericExceptionCaught") cause: Throwable) {
+ } catch (cause: Throwable) {
val serializedReason = serializeException(cause)
val message = KrpcCallMessage.StreamCancel(
callId = outgoingStream.callId,
diff --git a/krpc/krpc-client/src/commonMain/kotlin/kotlinx/rpc/krpc/client/internal/KrpcClientConnector.kt b/krpc/krpc-client/src/commonMain/kotlin/kotlinx/rpc/krpc/client/internal/KrpcClientConnector.kt
index ebee09b5c..f0fd313b2 100644
--- a/krpc/krpc-client/src/commonMain/kotlin/kotlinx/rpc/krpc/client/internal/KrpcClientConnector.kt
+++ b/krpc/krpc-client/src/commonMain/kotlin/kotlinx/rpc/krpc/client/internal/KrpcClientConnector.kt
@@ -1,43 +1,27 @@
/*
- * Copyright 2023-2024 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
+ * 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.krpc.client.internal
+import kotlinx.rpc.krpc.KrpcConfig
import kotlinx.rpc.krpc.KrpcTransport
import kotlinx.rpc.krpc.internal.*
import kotlinx.serialization.SerialFormat
-internal sealed interface CallSubscriptionId {
- data class Service(
- val serviceTypeString: String,
- val callId: String,
- ) : CallSubscriptionId
-
- data object Protocol : CallSubscriptionId
-
- data object Generic : CallSubscriptionId
-}
-
internal class KrpcClientConnector private constructor(
- private val connector: KrpcConnector
+ private val connector: KrpcConnector
) : KrpcMessageSender by connector {
constructor(
serialFormat: SerialFormat,
transport: KrpcTransport,
- waitForServices: Boolean = false,
+ config: KrpcConfig.Connector,
) : this(
- KrpcConnector(serialFormat, transport, waitForServices, isServer = false) {
- when (this) {
- is KrpcCallMessage -> CallSubscriptionId.Service(serviceType, callId)
- is KrpcProtocolMessage -> CallSubscriptionId.Protocol
- is KrpcGenericMessage -> CallSubscriptionId.Generic
- }
- }
+ KrpcConnector(serialFormat, transport, config, isServer = false)
)
- fun unsubscribeFromMessages(serviceTypeString: String, callId: String, callback: () -> Unit = {}) {
- connector.unsubscribeFromMessages(CallSubscriptionId.Service(serviceTypeString, callId), callback)
+ suspend fun unsubscribeFromMessages(serviceTypeString: String, callId: String) {
+ connector.unsubscribeFromMessages(HandlerKey.ServiceCall(serviceTypeString, callId))
}
suspend fun subscribeToCallResponse(
@@ -45,21 +29,20 @@ internal class KrpcClientConnector private constructor(
callId: String,
subscription: suspend (KrpcCallMessage) -> Unit,
) {
- connector.subscribeToMessages(CallSubscriptionId.Service(serviceTypeString, callId)) {
- subscription(it as KrpcCallMessage)
+ connector.subscribeToMessages(HandlerKey.ServiceCall(serviceTypeString, callId)) {
+ subscription(it)
}
}
suspend fun subscribeToProtocolMessages(subscription: suspend (KrpcProtocolMessage) -> Unit) {
- connector.subscribeToMessages(CallSubscriptionId.Protocol) {
- subscription(it as KrpcProtocolMessage)
+ connector.subscribeToMessages(HandlerKey.Protocol) {
+ subscription(it)
}
}
- @Suppress("unused")
suspend fun subscribeToGenericMessages(subscription: suspend (KrpcGenericMessage) -> Unit) {
- connector.subscribeToMessages(CallSubscriptionId.Generic) {
- subscription(it as KrpcGenericMessage)
+ connector.subscribeToMessages(HandlerKey.Generic) {
+ subscription(it)
}
}
}
diff --git a/krpc/krpc-core/api/krpc-core.api b/krpc/krpc-core/api/krpc-core.api
index 7b7696b17..c3f4c2cbf 100644
--- a/krpc/krpc-core/api/krpc-core.api
+++ b/krpc/krpc-core/api/krpc-core.api
@@ -1,19 +1,34 @@
public abstract interface class kotlinx/rpc/krpc/KrpcConfig {
+ public abstract fun getConnector ()Lkotlinx/rpc/krpc/KrpcConfig$Connector;
public abstract fun getSerialFormatInitializer ()Lkotlinx/rpc/krpc/serialization/KrpcSerialFormatBuilder;
public abstract fun getWaitForServices ()Z
}
public final class kotlinx/rpc/krpc/KrpcConfig$Client : kotlinx/rpc/krpc/KrpcConfig {
+ public fun getConnector ()Lkotlinx/rpc/krpc/KrpcConfig$Connector;
public fun getSerialFormatInitializer ()Lkotlinx/rpc/krpc/serialization/KrpcSerialFormatBuilder;
public fun getWaitForServices ()Z
}
+public final class kotlinx/rpc/krpc/KrpcConfig$Connector {
+ public final fun getCallTimeout-UwyO8pc ()J
+ public final fun getPerCallBufferSize ()I
+ public final fun getWaitTimeout-UwyO8pc ()J
+}
+
+public final class kotlinx/rpc/krpc/KrpcConfig$DefaultImpls {
+ public static fun getWaitForServices (Lkotlinx/rpc/krpc/KrpcConfig;)Z
+}
+
public final class kotlinx/rpc/krpc/KrpcConfig$Server : kotlinx/rpc/krpc/KrpcConfig {
+ public fun getConnector ()Lkotlinx/rpc/krpc/KrpcConfig$Connector;
public fun getSerialFormatInitializer ()Lkotlinx/rpc/krpc/serialization/KrpcSerialFormatBuilder;
public fun getWaitForServices ()Z
}
public abstract class kotlinx/rpc/krpc/KrpcConfigBuilder {
+ public final fun buildConnector ()Lkotlinx/rpc/krpc/KrpcConfig$Connector;
+ public final fun connector (Lkotlin/jvm/functions/Function1;)V
public final fun getWaitForServices ()Z
protected final fun rpcSerialFormat ()Lkotlinx/rpc/krpc/serialization/KrpcSerialFormatBuilder;
public final fun serialization (Lkotlin/jvm/functions/Function1;)V
@@ -25,6 +40,17 @@ public final class kotlinx/rpc/krpc/KrpcConfigBuilder$Client : kotlinx/rpc/krpc/
public final fun build ()Lkotlinx/rpc/krpc/KrpcConfig$Client;
}
+public final class kotlinx/rpc/krpc/KrpcConfigBuilder$Connector {
+ public fun ()V
+ public final fun dontWait-UwyO8pc ()J
+ public final fun getCallTimeout-UwyO8pc ()J
+ public final fun getPerCallBufferSize ()I
+ public final fun getWaitTimeout-UwyO8pc ()J
+ public final fun setCallTimeout-LRDsOJo (J)V
+ public final fun setPerCallBufferSize (I)V
+ public final fun setWaitTimeout-LRDsOJo (J)V
+}
+
public final class kotlinx/rpc/krpc/KrpcConfigBuilder$Server : kotlinx/rpc/krpc/KrpcConfigBuilder {
public fun ()V
public final fun build ()Lkotlinx/rpc/krpc/KrpcConfig$Server;
diff --git a/krpc/krpc-core/api/krpc-core.klib.api b/krpc/krpc-core/api/krpc-core.klib.api
index 3193d1b57..ac0c04c74 100644
--- a/krpc/krpc-core/api/krpc-core.klib.api
+++ b/krpc/krpc-core/api/krpc-core.klib.api
@@ -12,23 +12,34 @@ abstract interface kotlinx.rpc.krpc/KrpcTransport : kotlinx.coroutines/Coroutine
}
sealed interface kotlinx.rpc.krpc/KrpcConfig { // kotlinx.rpc.krpc/KrpcConfig|null[0]
+ abstract val connector // kotlinx.rpc.krpc/KrpcConfig.connector|{}connector[0]
+ abstract fun (): kotlinx.rpc.krpc/KrpcConfig.Connector // kotlinx.rpc.krpc/KrpcConfig.connector.|(){}[0]
abstract val serialFormatInitializer // kotlinx.rpc.krpc/KrpcConfig.serialFormatInitializer|{}serialFormatInitializer[0]
abstract fun (): kotlinx.rpc.krpc.serialization/KrpcSerialFormatBuilder<*, *> // kotlinx.rpc.krpc/KrpcConfig.serialFormatInitializer.|(){}[0]
- abstract val waitForServices // kotlinx.rpc.krpc/KrpcConfig.waitForServices|{}waitForServices[0]
- abstract fun (): kotlin/Boolean // kotlinx.rpc.krpc/KrpcConfig.waitForServices.|(){}[0]
+ open val waitForServices // kotlinx.rpc.krpc/KrpcConfig.waitForServices|{}waitForServices[0]
+ open fun (): kotlin/Boolean // kotlinx.rpc.krpc/KrpcConfig.waitForServices.|(){}[0]
final class Client : kotlinx.rpc.krpc/KrpcConfig { // kotlinx.rpc.krpc/KrpcConfig.Client|null[0]
+ final val connector // kotlinx.rpc.krpc/KrpcConfig.Client.connector|{}connector[0]
+ final fun (): kotlinx.rpc.krpc/KrpcConfig.Connector // kotlinx.rpc.krpc/KrpcConfig.Client.connector.|(){}[0]
final val serialFormatInitializer // kotlinx.rpc.krpc/KrpcConfig.Client.serialFormatInitializer|{}serialFormatInitializer[0]
final fun (): kotlinx.rpc.krpc.serialization/KrpcSerialFormatBuilder<*, *> // kotlinx.rpc.krpc/KrpcConfig.Client.serialFormatInitializer.|(){}[0]
- final val waitForServices // kotlinx.rpc.krpc/KrpcConfig.Client.waitForServices|{}waitForServices[0]
- final fun (): kotlin/Boolean // kotlinx.rpc.krpc/KrpcConfig.Client.waitForServices.|(){}[0]
+ }
+
+ final class Connector { // kotlinx.rpc.krpc/KrpcConfig.Connector|null[0]
+ final val callTimeout // kotlinx.rpc.krpc/KrpcConfig.Connector.callTimeout|{}callTimeout[0]
+ final fun (): kotlin.time/Duration // kotlinx.rpc.krpc/KrpcConfig.Connector.callTimeout.|(){}[0]
+ final val perCallBufferSize // kotlinx.rpc.krpc/KrpcConfig.Connector.perCallBufferSize|{}perCallBufferSize[0]
+ final fun (): kotlin/Int // kotlinx.rpc.krpc/KrpcConfig.Connector.perCallBufferSize.|(){}[0]
+ final val waitTimeout // kotlinx.rpc.krpc/KrpcConfig.Connector.waitTimeout|{}waitTimeout[0]
+ final fun (): kotlin.time/Duration // kotlinx.rpc.krpc/KrpcConfig.Connector.waitTimeout.|(){}[0]
}
final class Server : kotlinx.rpc.krpc/KrpcConfig { // kotlinx.rpc.krpc/KrpcConfig.Server|null[0]
+ final val connector // kotlinx.rpc.krpc/KrpcConfig.Server.connector|{}connector[0]
+ final fun (): kotlinx.rpc.krpc/KrpcConfig.Connector // kotlinx.rpc.krpc/KrpcConfig.Server.connector.|(){}[0]
final val serialFormatInitializer // kotlinx.rpc.krpc/KrpcConfig.Server.serialFormatInitializer|{}serialFormatInitializer[0]
final fun (): kotlinx.rpc.krpc.serialization/KrpcSerialFormatBuilder<*, *> // kotlinx.rpc.krpc/KrpcConfig.Server.serialFormatInitializer.|(){}[0]
- final val waitForServices // kotlinx.rpc.krpc/KrpcConfig.Server.waitForServices|{}waitForServices[0]
- final fun (): kotlin/Boolean // kotlinx.rpc.krpc/KrpcConfig.Server.waitForServices.|(){}[0]
}
}
@@ -53,6 +64,8 @@ sealed class kotlinx.rpc.krpc/KrpcConfigBuilder { // kotlinx.rpc.krpc/KrpcConfig
final fun (): kotlin/Boolean // kotlinx.rpc.krpc/KrpcConfigBuilder.waitForServices.|(){}[0]
final fun (kotlin/Boolean) // kotlinx.rpc.krpc/KrpcConfigBuilder.waitForServices.|(kotlin.Boolean){}[0]
+ final fun buildConnector(): kotlinx.rpc.krpc/KrpcConfig.Connector // kotlinx.rpc.krpc/KrpcConfigBuilder.buildConnector|buildConnector(){}[0]
+ final fun connector(kotlin/Function1) // kotlinx.rpc.krpc/KrpcConfigBuilder.connector|connector(kotlin.Function1){}[0]
final fun rpcSerialFormat(): kotlinx.rpc.krpc.serialization/KrpcSerialFormatBuilder<*, *> // kotlinx.rpc.krpc/KrpcConfigBuilder.rpcSerialFormat|rpcSerialFormat(){}[0]
final fun serialization(kotlin/Function1) // kotlinx.rpc.krpc/KrpcConfigBuilder.serialization|serialization(kotlin.Function1){}[0]
@@ -62,6 +75,22 @@ sealed class kotlinx.rpc.krpc/KrpcConfigBuilder { // kotlinx.rpc.krpc/KrpcConfig
final fun build(): kotlinx.rpc.krpc/KrpcConfig.Client // kotlinx.rpc.krpc/KrpcConfigBuilder.Client.build|build(){}[0]
}
+ final class Connector { // kotlinx.rpc.krpc/KrpcConfigBuilder.Connector|null[0]
+ constructor () // kotlinx.rpc.krpc/KrpcConfigBuilder.Connector.|(){}[0]
+
+ final var callTimeout // kotlinx.rpc.krpc/KrpcConfigBuilder.Connector.callTimeout|{}callTimeout[0]
+ final fun (): kotlin.time/Duration // kotlinx.rpc.krpc/KrpcConfigBuilder.Connector.callTimeout.|(){}[0]
+ final fun (kotlin.time/Duration) // kotlinx.rpc.krpc/KrpcConfigBuilder.Connector.callTimeout.|(kotlin.time.Duration){}[0]
+ final var perCallBufferSize // kotlinx.rpc.krpc/KrpcConfigBuilder.Connector.perCallBufferSize|{}perCallBufferSize[0]
+ final fun (): kotlin/Int // kotlinx.rpc.krpc/KrpcConfigBuilder.Connector.perCallBufferSize.|(){}[0]
+ final fun (kotlin/Int) // kotlinx.rpc.krpc/KrpcConfigBuilder.Connector.perCallBufferSize.|(kotlin.Int){}[0]
+ final var waitTimeout // kotlinx.rpc.krpc/KrpcConfigBuilder.Connector.waitTimeout|{}waitTimeout[0]
+ final fun (): kotlin.time/Duration // kotlinx.rpc.krpc/KrpcConfigBuilder.Connector.waitTimeout.|(){}[0]
+ final fun (kotlin.time/Duration) // kotlinx.rpc.krpc/KrpcConfigBuilder.Connector.waitTimeout.|(kotlin.time.Duration){}[0]
+
+ final fun dontWait(): kotlin.time/Duration // kotlinx.rpc.krpc/KrpcConfigBuilder.Connector.dontWait|dontWait(){}[0]
+ }
+
final class Server : kotlinx.rpc.krpc/KrpcConfigBuilder { // kotlinx.rpc.krpc/KrpcConfigBuilder.Server|null[0]
constructor () // kotlinx.rpc.krpc/KrpcConfigBuilder.Server.|(){}[0]
diff --git a/krpc/krpc-core/build.gradle.kts b/krpc/krpc-core/build.gradle.kts
index d2ea2a8d0..36889112f 100644
--- a/krpc/krpc-core/build.gradle.kts
+++ b/krpc/krpc-core/build.gradle.kts
@@ -2,6 +2,9 @@
* Copyright 2023-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/
+import org.gradle.kotlin.dsl.withType
+import org.jetbrains.kotlin.gradle.targets.jvm.tasks.KotlinJvmTest
+
plugins {
alias(libs.plugins.conventions.kmp)
alias(libs.plugins.serialization)
@@ -24,5 +27,28 @@ kotlin {
implementation(libs.kotlin.reflect)
}
}
+
+ commonTest {
+ dependencies {
+ implementation(projects.tests.testUtils)
+
+ implementation(libs.kotlin.test)
+ implementation(libs.coroutines.test)
+ implementation(libs.serialization.json)
+ }
+ }
+
+ jvmTest {
+ dependencies {
+ implementation(libs.coroutines.debug)
+ implementation(libs.lincheck)
+ implementation(libs.logback.classic)
+ }
+ }
}
}
+
+tasks.withType {
+ // lincheck agent
+ jvmArgs("-XX:+EnableDynamicAgentLoading")
+}
diff --git a/krpc/krpc-core/src/commonMain/kotlin/kotlinx/rpc/krpc/KrpcConfig.kt b/krpc/krpc-core/src/commonMain/kotlin/kotlinx/rpc/krpc/KrpcConfig.kt
index 566afada2..3ba88ceae 100644
--- a/krpc/krpc-core/src/commonMain/kotlin/kotlinx/rpc/krpc/KrpcConfig.kt
+++ b/krpc/krpc-core/src/commonMain/kotlin/kotlinx/rpc/krpc/KrpcConfig.kt
@@ -7,6 +7,8 @@ package kotlinx.rpc.krpc
import kotlinx.rpc.krpc.serialization.KrpcSerialFormat
import kotlinx.rpc.krpc.serialization.KrpcSerialFormatBuilder
import kotlinx.rpc.krpc.serialization.KrpcSerialFormatConfiguration
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
/**
* Builder for [KrpcConfig]. Provides DSL to configure parameters for KrpcClient and/or KrpcServer.
@@ -32,11 +34,60 @@ public sealed class KrpcConfigBuilder protected constructor() {
}
/**
- * A flag indicating whether a client or a server should wait for subscribers
- * if no service is available to process a message immediately.
- * If `false`, the endpoint that sent the unprocessed message will receive a call exception
- * saying there were no services to process the message.
+ * DSL for connector configuration.
+ *
+ * Connector is responsible for handling all messages transferring.
+ * Example usage:
+ * ```kotlin
+ * connector {
+ * waitTimeout = 10.seconds
+ * callTimeout = 10.seconds
+ * perCallBufferSize = 1000
+ * }
+ * ```
*/
+ public fun connector(builder: Connector.() -> Unit) {
+ connector.builder()
+ }
+
+ /**
+ * Configuration for RPC connector - a handler for all messages transferring.
+ */
+ public class Connector {
+ /**
+ * A flag indicating how long a client or a server should wait for subscribers
+ * if no service is available to process a message immediately.
+ * If negative ([dontWait]) or when timeout is exceeded,
+ * the endpoint that sent the unprocessed message will receive a call exception
+ * saying there were no services to process the message.
+ */
+ public var waitTimeout: Duration = Duration.INFINITE
+
+ /**
+ * A flag indicating that a client or a server should not wait for subscribers
+ *
+ * @see Connector.waitTimeout
+ */
+ public fun dontWait(): Duration = (-1).seconds
+
+ /**
+ * A timeout for a call.
+ * If a call is not completed in this time, it will be cancelled with a call exception.
+ */
+ public var callTimeout: Duration = Duration.INFINITE
+
+ /**
+ * A buffer size for a single call.
+ *
+ * The default value is 1,
+ * meaning that only after one message is handled - the next one will be sent.
+ *
+ * This buffer also applies to how many messages are cached with [waitTimeout]
+ */
+ public var perCallBufferSize: Int = 1
+ }
+
+ @Deprecated("Use connector { } instead", level = DeprecationLevel.ERROR)
public var waitForServices: Boolean = true
/**
@@ -46,7 +97,7 @@ public sealed class KrpcConfigBuilder protected constructor() {
public fun build(): KrpcConfig.Client {
return KrpcConfig.Client(
serialFormatInitializer = rpcSerialFormat(),
- waitForServices = waitForServices,
+ connector = buildConnector(),
)
}
}
@@ -58,7 +109,7 @@ public sealed class KrpcConfigBuilder protected constructor() {
public fun build(): KrpcConfig.Server {
return KrpcConfig.Server(
serialFormatInitializer = rpcSerialFormat(),
- waitForServices = waitForServices,
+ connector = buildConnector(),
)
}
}
@@ -73,6 +124,12 @@ public sealed class KrpcConfigBuilder protected constructor() {
private var serialFormatInitializer: KrpcSerialFormatBuilder<*, *>? = null
+ private val connector = Connector()
+
+ public fun buildConnector(): KrpcConfig.Connector {
+ return KrpcConfig.Connector(connector.waitTimeout, connector.callTimeout, connector.perCallBufferSize)
+ }
+
private val configuration = object : KrpcSerialFormatConfiguration {
override fun register(rpcSerialFormatInitializer: KrpcSerialFormatBuilder.Binary<*, *>) {
serialFormatInitializer = rpcSerialFormatInitializer
@@ -101,9 +158,36 @@ public sealed interface KrpcConfig {
public val serialFormatInitializer: KrpcSerialFormatBuilder<*, *>
/**
- * @see KrpcConfigBuilder.waitForServices
+ * @see KrpcConfigBuilder.connector
*/
- public val waitForServices: Boolean
+ public val connector: Connector
+
+ @Deprecated("Use connector instead", level = DeprecationLevel.ERROR)
+ public val waitForServices: Boolean get() = true
+
+ /**
+ * @see KrpcConfigBuilder.connector
+ */
+ public class Connector internal constructor(
+ /**
+ * @see KrpcConfigBuilder.Connector.waitTimeout
+ */
+ public val waitTimeout: Duration,
+
+ /**
+ * @see KrpcConfigBuilder.Connector.callTimeout
+ */
+ public val callTimeout: Duration,
+
+ /**
+ * @see KrpcConfigBuilder.Connector.perCallBufferSize
+ */
+ public val perCallBufferSize: Int,
+ ) {
+ init {
+ require(perCallBufferSize != 0) { "perCallBufferSize must not be zero" }
+ }
+ }
/**
* @see [KrpcConfig]
@@ -113,10 +197,8 @@ public sealed interface KrpcConfig {
* @see KrpcConfigBuilder.serialization
*/
override val serialFormatInitializer: KrpcSerialFormatBuilder<*, *>,
- /**
- * @see KrpcConfigBuilder.waitForServices
- */
- override val waitForServices: Boolean,
+
+ override val connector: Connector,
) : KrpcConfig
/**
@@ -127,10 +209,11 @@ public sealed interface KrpcConfig {
* @see KrpcConfigBuilder.serialization
*/
override val serialFormatInitializer: KrpcSerialFormatBuilder<*, *>,
+
/**
- * @see KrpcConfigBuilder.waitForServices
+ * @see KrpcConfigBuilder.connector
*/
- override val waitForServices: Boolean,
+ override val connector: Connector,
) : KrpcConfig
}
diff --git a/krpc/krpc-core/src/commonMain/kotlin/kotlinx/rpc/krpc/KrpcTransport.kt b/krpc/krpc-core/src/commonMain/kotlin/kotlinx/rpc/krpc/KrpcTransport.kt
index 563913f19..a70b816b6 100644
--- a/krpc/krpc-core/src/commonMain/kotlin/kotlinx/rpc/krpc/KrpcTransport.kt
+++ b/krpc/krpc-core/src/commonMain/kotlin/kotlinx/rpc/krpc/KrpcTransport.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2023-2024 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
+ * 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.krpc
diff --git a/krpc/krpc-core/src/commonMain/kotlin/kotlinx/rpc/krpc/internal/BufferResult.kt b/krpc/krpc-core/src/commonMain/kotlin/kotlinx/rpc/krpc/internal/BufferResult.kt
new file mode 100644
index 000000000..9e4398df5
--- /dev/null
+++ b/krpc/krpc-core/src/commonMain/kotlin/kotlinx/rpc/krpc/internal/BufferResult.kt
@@ -0,0 +1,40 @@
+/*
+ * 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.krpc.internal
+
+internal sealed interface BufferResult {
+ class Success(val message: T) : BufferResult
+ class Failure : BufferResult
+ class Closed(val cause: Throwable?) : BufferResult
+}
+
+internal fun BufferResult.getOrNull(): T? {
+ return if (this is BufferResult.Success) message else null
+}
+
+internal inline fun BufferResult.onFailure(body: () -> Unit): BufferResult {
+ if (this is BufferResult.Failure) {
+ body()
+ }
+
+ return this
+}
+
+internal inline fun BufferResult.onClosed(body: (Throwable?) -> Unit): BufferResult {
+ if (this is BufferResult.Closed) {
+ body(cause)
+ }
+
+ return this
+}
+
+internal inline val BufferResult<*>.isFailure: Boolean
+ get() = this is BufferResult.Failure
+
+internal inline val BufferResult<*>.isSuccess: Boolean
+ get() = this is BufferResult.Success
+
+internal inline val BufferResult<*>.isClosed: Boolean
+ get() = this is BufferResult.Closed
diff --git a/krpc/krpc-core/src/commonMain/kotlin/kotlinx/rpc/krpc/internal/ExceptionUtils.kt b/krpc/krpc-core/src/commonMain/kotlin/kotlinx/rpc/krpc/internal/ExceptionUtils.kt
index 29f3dba64..10666295c 100644
--- a/krpc/krpc-core/src/commonMain/kotlin/kotlinx/rpc/krpc/internal/ExceptionUtils.kt
+++ b/krpc/krpc-core/src/commonMain/kotlin/kotlinx/rpc/krpc/internal/ExceptionUtils.kt
@@ -4,6 +4,7 @@
package kotlinx.rpc.krpc.internal
+import kotlinx.coroutines.CancellationException
import kotlinx.rpc.internal.rpcInternalTypeName
import kotlinx.rpc.internal.utils.InternalRpcApi
@@ -19,8 +20,46 @@ public fun serializeException(cause: Throwable): SerializedException {
internal expect fun Throwable.stackElements(): List
+internal expect fun SerializedException.deserializeUnsafe(): Throwable
+
+internal fun SerializedException.nonJvmManualCancellationExceptionDeserialize(): ManualCancellationException? {
+ if (className == ManualCancellationException::class.rpcInternalTypeName) {
+ val cancellation = cause?.deserializeUnsafe()
+ ?: error("ManualCancellationException must have a cause")
+
+ return ManualCancellationException(
+ CancellationException(
+ message = cancellation.message,
+ cause = cancellation.cause,
+ )
+ )
+ }
+
+ return null
+}
+
+@InternalRpcApi
+public fun SerializedException.deserialize(): Throwable {
+ val cause = runCatching {
+ deserializeUnsafe()
+ }
+
+ val result = if (cause.isFailure) {
+ cause.exceptionOrNull()!!
+ } else {
+ val ex = cause.getOrNull()!!
+ if (ex is ManualCancellationException) {
+ ex.cause
+ } else {
+ ex
+ }
+ }
+
+ return result
+}
+
@InternalRpcApi
-public expect fun SerializedException.deserialize(): Throwable
+public class ManualCancellationException(override val cause: CancellationException): RuntimeException()
internal expect class DeserializedException(
toStringMessage: String,
@@ -31,3 +70,8 @@ internal expect class DeserializedException(
) : Throwable {
override val message: String
}
+
+internal fun illegalStateException(message: String, cause: Throwable? = null): IllegalStateException = when (cause) {
+ null -> IllegalStateException(message)
+ else -> IllegalStateException(message, cause)
+}
diff --git a/krpc/krpc-core/src/commonMain/kotlin/kotlinx/rpc/krpc/internal/KrpcConnector.kt b/krpc/krpc-core/src/commonMain/kotlin/kotlinx/rpc/krpc/internal/KrpcConnector.kt
index bf4808223..a86001ea6 100644
--- a/krpc/krpc-core/src/commonMain/kotlin/kotlinx/rpc/krpc/internal/KrpcConnector.kt
+++ b/krpc/krpc-core/src/commonMain/kotlin/kotlinx/rpc/krpc/internal/KrpcConnector.kt
@@ -4,63 +4,86 @@
package kotlinx.rpc.krpc.internal
-import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.channels.ClosedSendChannelException
import kotlinx.coroutines.delay
+import kotlinx.coroutines.job
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
+import kotlinx.rpc.internal.internalRpcError
import kotlinx.rpc.internal.utils.InternalRpcApi
import kotlinx.rpc.internal.utils.map.RpcInternalConcurrentHashMap
+import kotlinx.rpc.krpc.KrpcConfig
import kotlinx.rpc.krpc.KrpcTransport
import kotlinx.rpc.krpc.KrpcTransportMessage
import kotlinx.rpc.krpc.internal.logging.RpcInternalCommonLogger
import kotlinx.rpc.krpc.internal.logging.RpcInternalDumpLoggerContainer
import kotlinx.rpc.krpc.receiveCatching
import kotlinx.serialization.*
-import kotlin.time.Duration.Companion.seconds
@InternalRpcApi
-public interface KrpcMessageSender : CoroutineScope {
+public interface KrpcMessageSender {
+ public val transportScope: CoroutineScope
+
public suspend fun sendMessage(message: KrpcMessage)
+
+ public fun drainSendQueueAndClose(message: String)
}
-private typealias KrpcMessageHandler = suspend (KrpcMessage) -> Unit
+internal typealias KrpcMessageSubscription = suspend (Message) -> Unit
/**
* Represents a connector for remote procedure call (RPC) communication.
* This class is responsible for sending and receiving [KrpcMessage] over a specified transport.
*
- * @param SubscriptionKey the type of the subscription key used for message subscriptions.
- * @param serialFormat the serial format used for encoding and decoding [KrpcMessage].
- * @param transport the transport used for sending and receiving encoded RPC messages.
- * @param waitForSubscribers a flag indicating whether the connector should wait for subscribers
- * if no service is available to process the message immediately.
- * If false, the endpoint that sent the message will receive a [KrpcCallMessage.CallException]
- * that says that there were no services to process its message.
- * @param isServer flag indication whether this is a server or a client.
- * @param getKey a lambda function that returns the subscription key for a given [KrpcCallMessage].
* DO NOT use actual dumper in production!
*/
@InternalRpcApi
-public class KrpcConnector(
+public class KrpcConnector(
private val serialFormat: SerialFormat,
private val transport: KrpcTransport,
- private val waitForSubscribers: Boolean = true,
+ private val config: KrpcConfig.Connector,
isServer: Boolean,
- private val getKey: KrpcMessage.() -> SubscriptionKey,
-) : KrpcMessageSender, CoroutineScope by transport {
+) : KrpcMessageSender {
+ override val transportScope: CoroutineScope = transport
+
private val role = if (isServer) SERVER_ROLE else CLIENT_ROLE
- private val logger = RpcInternalCommonLogger.logger(rpcInternalObjectId(role))
+ private val logger = RpcInternalCommonLogger.logger("$role Connector")
+
+ private val receiveHandlers = RpcInternalConcurrentHashMap, KrpcReceiveHandler>()
+ private val keyLocks = RpcInternalConcurrentHashMap, Mutex>()
+ private val serviceSubscriptions =
+ RpcInternalConcurrentHashMap>()
- private val waiting = RpcInternalConcurrentHashMap>()
- private val subscriptions = RpcInternalConcurrentHashMap()
- private val processWaitersLocks = RpcInternalConcurrentHashMap()
+ private val sendHandlers = RpcInternalConcurrentHashMap, KrpcSendHandler>()
+ private val sendChannel = Channel(Channel.UNLIMITED)
+
+ private var receiveBufferSize: Int? = null
+ private var sendBufferSize: Int? = null
+
+ private var peerSupportsBackPressure = false
private val dumpLogger by lazy { RpcInternalDumpLoggerContainer.provide() }
+ // prevent errors ping-pong
+ private suspend fun sendMessageIgnoreClosure(message: KrpcMessage) {
+ try {
+ sendMessage(message)
+ } catch (_: ClosedSendChannelException) {
+ // ignore
+ }
+ }
+
override suspend fun sendMessage(message: KrpcMessage) {
+ if (message is KrpcProtocolMessage.Handshake) {
+ message.pluginParams[KrpcPluginKey.WINDOW_UPDATE] = "${config.perCallBufferSize}"
+ }
+
val transportMessage = when (serialFormat) {
is StringFormat -> {
KrpcTransportMessage.StringMessage(serialFormat.encodeToString(message))
@@ -79,177 +102,442 @@ public class KrpcConnector(
dumpLogger.dump(role, SEND_PHASE) { transportMessage.dump() }
}
- transport.send(transportMessage)
+ sendTransportMessage(message.handlerKey(), transportMessage)
+ }
+
+ private suspend fun sendTransportMessage(key: HandlerKey<*>, message: KrpcTransportMessage) {
+ sendHandlers.computeIfAbsent(key) {
+ KrpcSendHandler(sendChannel).also {
+ // - fallback to unlimited buffer if no buffer size is specified
+ // this is for backwards compatibility
+ // - use unlimited size for protocol messages
+ val initialSize = when (key) {
+ HandlerKey.Protocol, HandlerKey.Generic -> -1
+ else -> sendBufferSize ?: -1
+ }
+
+ it.updateWindowSize(initialSize)
+ }
+ }.sendMessage(message)
}
- public fun unsubscribeFromMessages(key: SubscriptionKey, callback: () -> Unit = {}) {
- launch(CoroutineName("krpc-connector-unsubscribe-$key")) {
- delay(15.seconds)
- subscriptions.remove(key)
- processWaitersLocks.remove(key)
- }.invokeOnCompletion {
+ public fun unsubscribeFromMessagesAsync(key: HandlerKey<*>, callback: suspend () -> Unit = {}) {
+ if (!keyLocks.containsKey(key)) {
+ println("Key $key is not registered")
+ return
+ }
+
+ transportScope.launch(CoroutineName("krpc-connector-unsubscribe-$key")) {
+ unsubscribeFromMessages(key)
+
callback()
}
}
- public suspend fun subscribeToMessages(key: SubscriptionKey, handler: KrpcMessageHandler) {
- subscriptions[key] = handler
- processWaiters(key, handler)
+ public suspend fun unsubscribeFromMessages(key: HandlerKey<*>) {
+ withLockForKey(key, createKey = false) {
+ if (key is HandlerKey.Service) {
+ receiveHandlers.withKeys { keys ->
+ keys
+ .filter { it is HandlerKey.ServiceCall && it.serviceType == key.serviceType }
+ .forEach {
+ cleanForKey(it)
+ }
+ }
+
+ serviceSubscriptions.remove(key)
+ } else {
+ cleanForKey(key)
+ }
+ }
+ }
+
+ private fun cleanForKey(key: HandlerKey<*>) {
+ sendHandlers.remove(key)?.close(null)
+ receiveHandlers.remove(key)?.close(key, null)
+ keyLocks.remove(key)
+ }
+
+ public suspend fun subscribeToMessages(
+ key: HandlerKey,
+ subscription: KrpcMessageSubscription,
+ ) {
+ withLockForKey(key, createKey = true) {
+ if (key is HandlerKey.Service) {
+ serviceSubscriptions.computeIfAbsent(key) {
+ @Suppress("UNCHECKED_CAST")
+ subscription as KrpcMessageSubscription
+ }
+
+ receiveHandlers.withKeys { keys ->
+ keys
+ .filter { it is HandlerKey.ServiceCall && it.serviceType == key.serviceType }
+ .forEach {
+ @Suppress("UNCHECKED_CAST")
+ subscribeWithActingHandlerPerTrack(it as HandlerKey, subscription)
+ }
+ }
+ } else {
+ subscribeWithActingHandlerPerTrack(key, subscription)
+ }
+ }
+ }
+
+ // per track:
+ // - call id is a track
+ // - generic is a track
+ // - protocol is a track
+ // - service is **not** a track
+ private fun subscribeWithActingHandlerPerTrack(
+ key: HandlerKey,
+ subscription: KrpcMessageSubscription,
+ ) {
+ val storingHandler = handlerFor(key)
+
+ if (storingHandler !is KrpcStoringReceiveHandler) {
+ internalRpcError("Already subscribed to messages with key $key")
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ val handler = KrpcActingReceiveHandler(
+ callHandler = subscription as KrpcMessageSubscription,
+ storingHandler = storingHandler,
+ key = key,
+ sender = this,
+ timeout = config.callTimeout,
+ broadcastUpdates = peerSupportsBackPressure,
+ )
+
+ receiveHandlers[key] = handler
+ }
+
+ private fun handlerFor(key: HandlerKey): KrpcReceiveHandler {
+ if (key is HandlerKey.Service) {
+ internalRpcError("Wrong key type for a handler: $key")
+ }
+
+ return receiveHandlers.computeIfAbsent(key) {
+ val storing = KrpcStoringReceiveHandler(
+ buffer = KrpcReceiveBuffer {
+ // - fallback to unlimited buffer if no buffer size is specified
+ // this is for backwards compatibility
+ // - use unlimited size for protocol messages
+ when (key) {
+ HandlerKey.Protocol, HandlerKey.Generic -> Channel.UNLIMITED
+ else -> receiveBufferSize ?: Channel.UNLIMITED
+ }
+ },
+ sender = this,
+ )
+
+ if (key is HandlerKey.ServiceCall && role == SERVER_ROLE) {
+ val serviceKey = HandlerKey.Service(key.serviceType)
+ val subscription = serviceSubscriptions[serviceKey]
+ if (subscription != null) {
+ @Suppress("UNCHECKED_CAST")
+ KrpcActingReceiveHandler(
+ callHandler = subscription as KrpcMessageSubscription,
+ storingHandler = storing,
+ key = key,
+ sender = this,
+ timeout = config.callTimeout,
+ broadcastUpdates = peerSupportsBackPressure,
+ )
+ } else {
+ storing
+ }
+ } else {
+ storing
+ }
+ }
+ }
+
+ @Suppress("JoinDeclarationAndAssignment")
+ private val sendJob: Job
+
+ override fun drainSendQueueAndClose(message: String) {
+ // don't close receive handlers, as
+ // we don't want to send 'unprocessed messages' messages
+ // because peer is closing entirely anyway
+ //
+ // don't close send handlers, as we want to process them all
+
+ // close for receiving new messages
+ sendChannel.close()
+
+ sendJob.invokeOnCompletion {
+ transportScope.cancel(message)
+ }
}
init {
- launch(CoroutineName("krpc-connector-receive-loop")) {
+ sendJob = transportScope.launch(CoroutineName("krpc-connector-send-loop")) {
+ while (true) {
+ transport.send(sendChannel.receiveCatching().getOrNull() ?: break)
+ }
+ }
+
+ transportScope.launch(CoroutineName("krpc-connector-receive-loop")) {
while (true) {
processMessage(transport.receiveCatching().getOrNull() ?: break)
}
}
+
+ transportScope.coroutineContext.job.invokeOnCompletion {
+ receiveHandlers.clear()
+ keyLocks.clear()
+ serviceSubscriptions.clear()
+ sendHandlers.clear()
+ }
}
- private suspend fun processMessage(transportMessage: KrpcTransportMessage) {
- val message: KrpcMessage = when {
- serialFormat is StringFormat && transportMessage is KrpcTransportMessage.StringMessage -> {
- serialFormat.decodeFromString(transportMessage.value)
- }
+ private fun decodeMessage(transportMessage: KrpcTransportMessage): KrpcMessage? {
+ try {
+ return when {
+ serialFormat is StringFormat && transportMessage is KrpcTransportMessage.StringMessage -> {
+ // otherwise KrpcMessage? is inferred which leads to decode exception
+ serialFormat.decodeFromString(transportMessage.value)
+ }
- serialFormat is BinaryFormat && transportMessage is KrpcTransportMessage.BinaryMessage -> {
- serialFormat.decodeFromByteArray(transportMessage.value)
- }
+ serialFormat is BinaryFormat && transportMessage is KrpcTransportMessage.BinaryMessage -> {
+ // otherwise KrpcMessage? is inferred which leads to decode exception
+ serialFormat.decodeFromByteArray(transportMessage.value)
+ }
- else -> {
- return
+ else -> {
+ logger.error {
+ "Unsupported serialization format: ${serialFormat::class} for ${transportMessage::class}"
+ }
+
+ return null
+ }
}
+ } catch (e: SerializationException) {
+ logger.error(e) { "Failed to decode transport message" }
+ logger.debug { "Failed message: ${transportMessage.dump()}" }
+ } catch (e: IllegalArgumentException) {
+ logger.error(e) { "Decoded message is not a valid ${KrpcMessage::class}" }
+ logger.debug { "Invalid message: ${transportMessage.dump()}" }
}
+ return null
+ }
+
+ private suspend fun processMessage(transportMessage: KrpcTransportMessage) {
+ val message = decodeMessage(transportMessage) ?: return
+
if (dumpLogger.isEnabled) {
dumpLogger.dump(role, RECEIVE_PHASE) { transportMessage.dump() }
}
- processMessage(message)
+ processMessage(message, message.handlerKey())
}
- private suspend fun processMessage(message: KrpcMessage) = withLockForKey(message.getKey()) {
- when (message) {
- is KrpcCallMessage -> processServiceMessage(message)
- is KrpcProtocolMessage, is KrpcGenericMessage -> processNonServiceMessage(message)
+ private suspend fun processMessage(message: KrpcMessage, key: HandlerKey<*>) {
+ when {
+ message is KrpcCallMessage -> {
+ @Suppress("UNCHECKED_CAST")
+ processServiceMessage(message, key as HandlerKey)
+ }
+
+ message is KrpcGenericMessage && message.pluginParams.orEmpty().contains(KrpcPluginKey.WINDOW_UPDATE) -> {
+ processWindow(message)
+ }
+
+ message is KrpcProtocolMessage || message is KrpcGenericMessage -> {
+ processNonServiceMessage(message, key)
+ }
+
+ else -> {
+ logger.error { "Received message of unknown processing: $message" }
+ }
}
}
- private suspend fun processNonServiceMessage(message: KrpcMessage) {
- when (val result = tryHandle(message)) {
- is HandlerResult.Failure -> {
- val failure = KrpcProtocolMessage.Failure(
- connectionId = message.connectionId,
- errorMessage = "Failed to process ${message::class.simpleName}, error: ${result.cause?.message}",
- failedMessage = message,
- )
+ private suspend fun processWindow(message: KrpcGenericMessage) {
+ when (val result = decodeWindow(message)) {
+ is WindowResult.Success -> {
+ // must be 'key' from 'result'
+ sendHandlers[result.key]?.updateWindowSize(result.update)
+ }
- sendMessage(failure)
+ is WindowResult.Failure -> {
+ sendMessageIgnoreClosure(
+ KrpcProtocolMessage.Failure(
+ errorMessage = result.message,
+ connectionId = message.connectionId,
+ failedMessage = message,
+ )
+ )
}
+ }
+ }
- HandlerResult.NoSubscription -> {
- waiting.computeIfAbsent(message.getKey()) { mutableListOf() }.add(message)
+ private suspend fun processNonServiceMessage(message: KrpcMessage, key: HandlerKey<*>) {
+ // should be the first message we receive
+ if (message is KrpcProtocolMessage.Handshake) {
+ if (message.supportedPlugins.contains(KrpcPlugin.BACKPRESSURE)) {
+ peerSupportsBackPressure = true
+
+ // If it is a new version peer, it will be set to the buffer size from the config
+ receiveBufferSize = config.perCallBufferSize
+
+ val windowParam = message.pluginParams[KrpcPluginKey.WINDOW_UPDATE]
+ ?.toIntOrNull()
+
+ // -1 for an unlimited buffer size for old peers
+ sendBufferSize = windowParam ?: -1
+ } else {
+ // UNLIMITED for backwards compatibility
+ receiveBufferSize = Channel.UNLIMITED
+ // -1 for an unlimited buffer size for old peers for backwards compatibility
+ sendBufferSize = -1
}
- HandlerResult.Success -> {} // ok
+ // update this if present, as this is the only sender that has -1 even for new clients
+ sendHandlers[HandlerKey.Protocol]?.updateWindowSize(sendBufferSize ?: -1)
}
- }
- private suspend fun processServiceMessage(message: KrpcCallMessage) {
- val result = tryHandle(message)
+ withLockForKey(key, createKey = true) {
+ val handler = handlerFor(key)
- // todo better exception processing probably
- if (result != HandlerResult.Success) {
- if (waitForSubscribers) {
- waiting.computeIfAbsent(message.getKey()) { mutableListOf() }.add(message)
+ handler.handle(message) { cause ->
+ if (message.isException) {
+ return@handle
+ }
- val reason = when (result) {
- is HandlerResult.Failure -> {
- "Unhandled exception while processing ${result.cause?.message}"
- }
+ val failure = KrpcProtocolMessage.Failure(
+ connectionId = message.connectionId,
+ errorMessage = "Failed to process $key, error: ${cause?.message}",
+ failedMessage = message,
+ )
- is HandlerResult.NoSubscription -> {
- "No service with key '${message.getKey()}' and '${message.serviceType}' type was registered. " +
- "Available: keys: [${subscriptions.keys.joinToString()}]"
- }
+ // too late for exceptions
+ sendMessageIgnoreClosure(failure)
+ }
+ }?.onFailure {
+ if (message.isException) {
+ return@onFailure
+ }
- else -> {
- "Unknown"
- }
+ val failure = KrpcProtocolMessage.Failure(
+ connectionId = message.connectionId,
+ errorMessage = "Message limit of ${config.perCallBufferSize} is exceeded for $key.",
+ failedMessage = message,
+ )
+
+ // too late for exceptions
+ sendMessageIgnoreClosure(failure)
+ }?.onClosed {
+ // do nothing; it's a service message, meaning that the service is dead
+ }
+ }
+
+ private suspend fun processServiceMessage(message: KrpcCallMessage, key: HandlerKey) {
+ val (handler, result) = withLockForKey(key, createKey = true) {
+ val handler = receiveHandlers[key]
+ ?: if (config.waitTimeout.isPositive()) {
+ handlerFor(key)
+ } else {
+ val errorMessage = "No registered service of ${message.serviceType} service type " +
+ "was able to process message ($key) at the moment. " +
+ "Available: keys: [${receiveHandlers.keys.joinToString()}]."
+
+ sendCallException(message, illegalStateException(errorMessage))
+ return
}
- logger.warn((result as? HandlerResult.Failure)?.cause) {
- "No registered service of ${message.serviceType} service type " +
- "was able to process message ($message) at the moment. Waiting for new services. " +
- "Reason: $reason"
+ val result = handler.handle(message) { initialCause ->
+ if (message.isException) {
+ return@handle
}
- return
+ val cause = illegalStateException(
+ message = "Failed to process call ${message.callId} for service ${message.serviceType}",
+ cause = initialCause,
+ )
+
+ sendCallException(message, cause)
}
- val initialCause = (result as? HandlerResult.Failure)?.cause
+ handler to result
+ } ?: error("unreachable, as we create a lock for the key")
- val cause = IllegalStateException(
- "Failed to process call ${message.callId} for service ${message.serviceType}, " +
- "${subscriptions.values.size} attempts failed",
- initialCause,
- )
+ result.onFailure {
+ if (message.isException) {
+ return@onFailure
+ }
- val callException = KrpcCallMessage.CallException(
- callId = message.callId,
- serviceType = message.serviceType,
- cause = serializeException(cause),
- connectionId = message.connectionId,
- serviceId = message.serviceId,
+ sendCallException(
+ message = message,
+ cause = illegalStateException("Message limit of ${config.perCallBufferSize} is exceeded for $key"),
)
+ }.onClosed {
+ if (message.isException) {
+ return@onClosed
+ }
- sendMessage(callException)
+ sendCallException(
+ message = message,
+ cause = illegalStateException("Service $key is dead"),
+ )
}
- }
- private suspend fun tryHandle(
- message: KrpcMessage,
- handler: KrpcMessageHandler? = null,
- ): HandlerResult {
- val key = message.getKey()
- val subscriber = handler ?: subscriptions[key] ?: return HandlerResult.NoSubscription
+ if (handler is KrpcStoringReceiveHandler && result.isSuccess) {
+ transportScope.launch(CoroutineName("krpc-connector-discard-if-unprocessed-$key")) {
+ delay(config.waitTimeout)
- val result = runCatching {
- subscriber(message)
- }
+ withLockForKey(key, createKey = false) {
+ if (handler.processingStarted || receiveHandlers[key] != handler) {
+ return@launch
+ }
- return when {
- result.isFailure -> {
- val exception = result.exceptionOrNull()
- if (exception !is CancellationException) {
- logger.error(exception) { "Failed to handle message with key $key" }
+ receiveHandlers.remove(key)
+ keyLocks.remove(key)
}
- HandlerResult.Failure(exception)
- }
- else -> {
- HandlerResult.Success
+ handler.close(
+ key = key,
+ e = illegalStateException(
+ "Waiting limit of ${config.waitTimeout} " +
+ "is exceeded for unprocessed messages with $key"
+ ),
+ )
}
}
}
- private suspend fun processWaiters(key: SubscriptionKey, handler: KrpcMessageHandler) {
- withLockForKey(key) {
- if (waiting.values.isEmpty()) return
+ private suspend fun sendCallException(message: KrpcCallMessage, cause: Throwable) {
+ val callException = KrpcCallMessage.CallException(
+ callId = message.callId,
+ serviceType = message.serviceType,
+ cause = serializeException(cause),
+ connectionId = message.connectionId,
+ serviceId = message.serviceId,
+ )
- val iterator = waiting[key]?.iterator() ?: return
- while (iterator.hasNext()) {
- val message = iterator.next()
+ sendMessage(callException)
+ }
- val tryHandle = tryHandle(message, handler)
- if (tryHandle == HandlerResult.Success) {
- iterator.remove()
- }
- }
+ private suspend inline fun withLockForKey(
+ key: HandlerKey<*>,
+ createKey: Boolean,
+ action: () -> T,
+ ): T? {
+ val lockKey = if (key is HandlerKey.ServiceCall && role == SERVER_ROLE) {
+ HandlerKey.Service(key.serviceType)
+ } else {
+ key
+ }
+
+ val mutex = if (createKey) {
+ keyLocks.computeIfAbsent(lockKey) { Mutex() }
+ } else {
+ keyLocks[lockKey]
}
- }
- private suspend inline fun withLockForKey(key: SubscriptionKey, action: () -> T): T =
- processWaitersLocks.computeIfAbsent(key) { Mutex() }.withLock(action = action)
+ return mutex?.withLock(action = action)
+ }
internal companion object {
const val SEND_PHASE = "Send"
@@ -268,10 +556,8 @@ public class KrpcConnector(
}
}
-private sealed interface HandlerResult {
- data object Success : HandlerResult
-
- data object NoSubscription : HandlerResult
-
- data class Failure(val cause: Throwable?) : HandlerResult
-}
+internal val KrpcMessage.isException
+ get() = when (this) {
+ is KrpcCallMessage.CallException, is KrpcProtocolMessage.Failure -> true
+ else -> false
+ }
diff --git a/krpc/krpc-core/src/commonMain/kotlin/kotlinx/rpc/krpc/internal/KrpcEndpoint.kt b/krpc/krpc-core/src/commonMain/kotlin/kotlinx/rpc/krpc/internal/KrpcEndpoint.kt
index 3289f0a24..2eb14c35b 100644
--- a/krpc/krpc-core/src/commonMain/kotlin/kotlinx/rpc/krpc/internal/KrpcEndpoint.kt
+++ b/krpc/krpc-core/src/commonMain/kotlin/kotlinx/rpc/krpc/internal/KrpcEndpoint.kt
@@ -1,11 +1,11 @@
/*
- * Copyright 2023-2024 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
+ * 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.krpc.internal
import kotlinx.coroutines.CoroutineName
-import kotlinx.coroutines.cancel
+import kotlinx.coroutines.channels.ClosedSendChannelException
import kotlinx.coroutines.launch
import kotlinx.rpc.internal.utils.InternalRpcApi
@@ -25,13 +25,15 @@ public interface KrpcEndpoint {
) {
if (!supportedPlugins.contains(KrpcPlugin.CANCELLATION)) {
if (closeTransportAfterSending) {
- sender.cancel("Transport finished")
+ sender.drainSendQueueAndClose("Transport finished")
}
return
}
- val sendJob = sender.launch(CoroutineName("krpc-endpoint-cancellation-$serviceId-$cancellationId")) {
+ val sendJob = sender.transportScope.launch(
+ CoroutineName("krpc-endpoint-cancellation-$serviceId-$cancellationId"),
+ ) {
val message = KrpcGenericMessage(
connectionId = null,
pluginParams = listOfNotNull(
@@ -42,12 +44,16 @@ public interface KrpcEndpoint {
).toMap()
)
- sender.sendMessage(message)
+ try {
+ sender.sendMessage(message)
+ } catch (_: ClosedSendChannelException) {
+ // ignore, call was already closed
+ }
}
if (closeTransportAfterSending) {
sendJob.invokeOnCompletion {
- sender.cancel("Transport finished")
+ sender.drainSendQueueAndClose("Transport finished")
}
}
}
diff --git a/krpc/krpc-core/src/commonMain/kotlin/kotlinx/rpc/krpc/internal/KrpcMessage.kt b/krpc/krpc-core/src/commonMain/kotlin/kotlinx/rpc/krpc/internal/KrpcMessage.kt
index 64b678038..6d7fdeba2 100644
--- a/krpc/krpc-core/src/commonMain/kotlin/kotlinx/rpc/krpc/internal/KrpcMessage.kt
+++ b/krpc/krpc-core/src/commonMain/kotlin/kotlinx/rpc/krpc/internal/KrpcMessage.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2023-2024 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
+ * 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.krpc.internal
@@ -59,7 +59,7 @@ public sealed interface KrpcProtocolMessage : KrpcMessage {
public data class Handshake(
val supportedPlugins: Set,
override val connectionId: Long? = null,
- override val pluginParams: Map = emptyMap(),
+ override val pluginParams: MutableMap = mutableMapOf(),
) : KrpcProtocolMessage
@InternalRpcApi
diff --git a/krpc/krpc-core/src/commonMain/kotlin/kotlinx/rpc/krpc/internal/KrpcPlugin.kt b/krpc/krpc-core/src/commonMain/kotlin/kotlinx/rpc/krpc/internal/KrpcPlugin.kt
index f4d4d7a82..e0e54beff 100644
--- a/krpc/krpc-core/src/commonMain/kotlin/kotlinx/rpc/krpc/internal/KrpcPlugin.kt
+++ b/krpc/krpc-core/src/commonMain/kotlin/kotlinx/rpc/krpc/internal/KrpcPlugin.kt
@@ -58,6 +58,11 @@ public enum class KrpcPlugin(
* Clients don't require cancellation acknowledgement from the peer server.
*/
NO_ACK_CANCELLATION(4, KrpcVersion.V_0_8_0),
+
+ /**
+ * Backpressure mechanism.
+ */
+ BACKPRESSURE(5, KrpcVersion.V_0_10_0),
;
@InternalRpcApi
diff --git a/krpc/krpc-core/src/commonMain/kotlin/kotlinx/rpc/krpc/internal/KrpcPluginKey.kt b/krpc/krpc-core/src/commonMain/kotlin/kotlinx/rpc/krpc/internal/KrpcPluginKey.kt
index 8401c6c43..66e59a796 100644
--- a/krpc/krpc-core/src/commonMain/kotlin/kotlinx/rpc/krpc/internal/KrpcPluginKey.kt
+++ b/krpc/krpc-core/src/commonMain/kotlin/kotlinx/rpc/krpc/internal/KrpcPluginKey.kt
@@ -51,6 +51,16 @@ public enum class KrpcPluginKey(
* Marks a request as a one doesn't suspend and returns a flow.
*/
NON_SUSPENDING_SERVER_FLOW_MARKER(5, KrpcPlugin.NON_SUSPENDING_SERVER_FLOWS),
+
+ /**
+ * Represents the update to the window size from the peer endpoint.
+ */
+ WINDOW_UPDATE(6, KrpcPlugin.BACKPRESSURE),
+
+ /**
+ * Represents the call id of the current [WINDOW_UPDATE].
+ */
+ WINDOW_KEY(7, KrpcPlugin.BACKPRESSURE),
;
init {
diff --git a/krpc/krpc-core/src/commonMain/kotlin/kotlinx/rpc/krpc/internal/KrpcReceiveBuffer.kt b/krpc/krpc-core/src/commonMain/kotlin/kotlinx/rpc/krpc/internal/KrpcReceiveBuffer.kt
new file mode 100644
index 000000000..85d1856a6
--- /dev/null
+++ b/krpc/krpc-core/src/commonMain/kotlin/kotlinx/rpc/krpc/internal/KrpcReceiveBuffer.kt
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2023-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
+ */
+
+package kotlinx.rpc.krpc.internal
+
+import kotlinx.atomicfu.atomic
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.channels.Channel
+
+internal class KrpcReceiveBuffer(
+ private val bufferSize: () -> Int,
+) {
+ data class MessageRequest(
+ val message: KrpcMessage,
+ val onMessageFailure: suspend (Throwable?) -> Unit,
+ )
+
+ private val _inBuffer = atomic(0)
+ val inBuffer get() = _inBuffer.value
+
+ private val _channelSize by lazy { bufferSize() }
+ val channel by lazy {
+ Channel(capacity = _channelSize)
+ }
+
+ val window get() = _channelSize - _inBuffer.value
+
+ suspend fun receiveCatching(): BufferResult {
+ val result = channel.receiveCatching()
+ if (result.isSuccess) {
+ _inBuffer.decrementAndGet()
+ }
+
+ return when {
+ result.isSuccess -> BufferResult.Success(result.getOrThrow())
+ result.isClosed -> BufferResult.Closed(result.exceptionOrNull())
+ result.isFailure -> BufferResult.Failure()
+ else -> error("Unreachable")
+ }
+ }
+
+ fun trySend(message: MessageRequest): BufferResult {
+ val result = channel.trySend(message)
+ if (result.isSuccess) {
+ _inBuffer.incrementAndGet()
+ }
+
+ return when {
+ result.isSuccess -> BufferResult.Success(Unit)
+ result.isClosed -> BufferResult.Closed(result.exceptionOrNull())
+ result.isFailure -> BufferResult.Failure()
+ else -> error("Unreachable")
+ }
+ }
+
+ fun close(cause: Throwable?) {
+ channel.close(cause)
+ channel.cancel(CancellationException(null, cause))
+
+ _inBuffer.getAndSet(_channelSize)
+ }
+}
diff --git a/krpc/krpc-core/src/commonMain/kotlin/kotlinx/rpc/krpc/internal/KrpcReceiveHandler.kt b/krpc/krpc-core/src/commonMain/kotlin/kotlinx/rpc/krpc/internal/KrpcReceiveHandler.kt
new file mode 100644
index 000000000..6e9c6ac3e
--- /dev/null
+++ b/krpc/krpc-core/src/commonMain/kotlin/kotlinx/rpc/krpc/internal/KrpcReceiveHandler.kt
@@ -0,0 +1,266 @@
+/*
+ * 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.krpc.internal
+
+import kotlinx.atomicfu.atomic
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineName
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.channels.ClosedSendChannelException
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withTimeoutOrNull
+import kotlinx.rpc.internal.utils.InternalRpcApi
+import kotlin.collections.component1
+import kotlin.collections.component2
+import kotlin.time.Duration
+
+@InternalRpcApi
+public sealed interface HandlerKey {
+ @InternalRpcApi
+ public data object Protocol : HandlerKey
+
+ @InternalRpcApi
+ public data object Generic : HandlerKey
+
+ @InternalRpcApi
+ public data class Service(val serviceType: String) : HandlerKey
+
+ @InternalRpcApi
+ public data class ServiceCall(val serviceType: String, val callId: String) : HandlerKey
+}
+
+internal fun Message.handlerKey(): HandlerKey {
+ @Suppress("UNCHECKED_CAST")
+ return when (this) {
+ is KrpcCallMessage -> HandlerKey.ServiceCall(serviceType, callId)
+ is KrpcProtocolMessage -> HandlerKey.Protocol
+ is KrpcGenericMessage -> HandlerKey.Generic
+ else -> error("unreachable")
+ } as HandlerKey
+}
+
+internal sealed interface KrpcReceiveHandler {
+ fun handle(
+ message: KrpcMessage,
+ onMessageFailure: suspend (Throwable?) -> Unit,
+ ): BufferResult
+
+ fun close(key: HandlerKey<*>, e: Throwable?)
+}
+
+internal class KrpcStoringReceiveHandler(
+ private val buffer: KrpcReceiveBuffer,
+ private val sender: KrpcMessageSender,
+) : KrpcReceiveHandler {
+ val inBuffer get() = buffer.inBuffer
+ private val closed = atomic(false)
+ private val scope get() = sender.transportScope
+
+ override fun handle(
+ message: KrpcMessage,
+ onMessageFailure: suspend (Throwable?) -> Unit,
+ ): BufferResult {
+ if (closed.value) {
+ return BufferResult.Closed(illegalStateException("KrpcStoringReceiveHandler closed"))
+ }
+
+ return buffer.trySend(KrpcReceiveBuffer.MessageRequest(message, onMessageFailure))
+ }
+
+ override fun close(key: HandlerKey<*>, e: Throwable?) {
+ if (!closed.compareAndSet(expect = false, update = true)) {
+ return
+ }
+
+ // close for sending
+ buffer.channel.close(e)
+
+ scope.launch(CoroutineName("krpc-connector-message-buffer-close-$key")) {
+ val allLeft = mutableListOf()
+
+ while (true) {
+ val result = buffer.channel.tryReceive()
+ if (result.isClosed || result.isFailure) {
+ break
+ }
+
+ allLeft.add(result.getOrThrow().message)
+ }
+
+ val undelivered = allLeft
+ .filterIsInstance() // ignore protocol messages
+
+ if (undelivered.isEmpty()) {
+ buffer.close(e)
+ return@launch
+ }
+
+ undelivered.groupBy { it.callId }.forEach { (callId, messages) ->
+ val message = messages.first()
+
+ val cause = illegalStateException(
+ message = "Buffer closed for $callId for service ${message.serviceType}, " +
+ "${messages.size} messages were unprocessed",
+ cause = e,
+ )
+
+ val callException = KrpcCallMessage.CallException(
+ callId = message.callId,
+ serviceType = message.serviceType,
+ cause = serializeException(cause),
+ connectionId = message.connectionId,
+ serviceId = message.serviceId,
+ )
+
+ this@KrpcStoringReceiveHandler.sender.sendMessage(callException)
+ }
+
+ buffer.close(e)
+ }
+ }
+
+ private val _processingStarted = atomic(false)
+ val processingStarted get() = _processingStarted.value
+
+ suspend fun receiveCatching(): BufferResult {
+ if (closed.value) {
+ return BufferResult.Closed(illegalStateException("KrpcStoringReceiveHandler closed"))
+ }
+
+ _processingStarted.getAndSet(true)
+ return buffer.receiveCatching()
+ }
+}
+
+internal class KrpcActingReceiveHandler(
+ private val callHandler: KrpcMessageSubscription,
+ private val storingHandler: KrpcStoringReceiveHandler,
+ private val sender: KrpcMessageSender,
+ key: HandlerKey<*>,
+ timeout: Duration,
+ broadcastUpdates: Boolean,
+) : KrpcReceiveHandler {
+ private val job = sender.transportScope.launch(
+ context = CoroutineName("krpc-connector-message-handler-$key"),
+ start = CoroutineStart.LAZY,
+ ) {
+ while (true) {
+ val (message, onMessageFailure) = storingHandler.receiveCatching().getOrNull() ?: break
+
+ val result = if (timeout == Duration.INFINITE) {
+ tryHandle(message)
+ } else {
+ withTimeoutOrNull(timeout) {
+ tryHandle(message)
+ } ?: HandlerResult.Failure(
+ illegalStateException("Timeout while processing message")
+ )
+ }
+
+ if (result is HandlerResult.Failure) {
+ onMessageFailure(result.cause)
+ }
+
+ if (message !is KrpcCallMessage) continue
+
+ if (broadcastUpdates) {
+ broadcastWindowUpdate(1, message.connectionId, message.serviceType, message.callId)
+ }
+ }
+ }
+
+ init {
+ if (storingHandler.inBuffer > 0) {
+ job.start()
+ }
+ }
+
+ override fun handle(
+ message: KrpcMessage,
+ onMessageFailure: suspend (Throwable?) -> Unit,
+ ): BufferResult {
+ job.start()
+ return storingHandler.handle(message, onMessageFailure)
+ }
+
+ override fun close(key: HandlerKey<*>, e: Throwable?) {
+ if (e != null) {
+ if (e is CancellationException) {
+ job.cancel(e)
+ } else {
+ job.cancel(CancellationException(null, e))
+ }
+ } else {
+ job.cancel()
+ }
+
+ storingHandler.close(key, e)
+ }
+
+ private suspend fun tryHandle(message: KrpcMessage): HandlerResult {
+ try {
+ callHandler(message)
+
+ return HandlerResult.Success
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Throwable) {
+ return HandlerResult.Failure(e)
+ }
+ }
+
+ internal suspend fun broadcastWindowUpdate(update: Int, connectionId: Long?, serviceType: String, callId: String) {
+ try {
+ sender.sendMessage(
+ KrpcGenericMessage(
+ connectionId = connectionId,
+ pluginParams = mutableMapOf(
+ KrpcPluginKey.WINDOW_UPDATE to "$update",
+ KrpcPluginKey.WINDOW_KEY to "$serviceType/$callId",
+ ),
+ )
+ )
+ } catch (_: ClosedSendChannelException) {
+ // ignore, connection is closed, no more channel updates are needed
+ }
+ }
+
+ private sealed interface HandlerResult {
+ data object Success : HandlerResult
+
+ data class Failure(val cause: Throwable?) : HandlerResult
+ }
+}
+
+internal sealed interface WindowResult {
+ data class Success(val update: Int, val key: HandlerKey.ServiceCall) : WindowResult
+
+ data class Failure(val message: String) : WindowResult
+}
+
+internal fun decodeWindow(message: KrpcGenericMessage): WindowResult {
+ val windowParam = message.pluginParams?.get(KrpcPluginKey.WINDOW_UPDATE)
+
+ if (windowParam.isNullOrEmpty()) {
+ return WindowResult.Failure("Window param must be of the form /")
+ }
+
+ val updateParam = windowParam.toIntOrNull()
+ ?: return WindowResult.Failure(
+ "Window param must be of the form / and in form of two longs"
+ )
+
+ val windowKey = message.pluginParams[KrpcPluginKey.WINDOW_KEY]?.split("/").orEmpty()
+ val serviceType = windowKey.getOrNull(0)
+ val callId = windowKey.getOrNull(1)
+
+ if (windowKey.size != 2 || serviceType == null || callId == null) {
+ return WindowResult.Failure("Window key must be of the form /")
+ }
+
+ val subscriptionKey = HandlerKey.ServiceCall(serviceType, callId)
+
+ return WindowResult.Success(updateParam, subscriptionKey)
+}
diff --git a/krpc/krpc-core/src/commonMain/kotlin/kotlinx/rpc/krpc/internal/KrpcSendHandler.kt b/krpc/krpc-core/src/commonMain/kotlin/kotlinx/rpc/krpc/internal/KrpcSendHandler.kt
new file mode 100644
index 000000000..ad55a5434
--- /dev/null
+++ b/krpc/krpc-core/src/commonMain/kotlin/kotlinx/rpc/krpc/internal/KrpcSendHandler.kt
@@ -0,0 +1,142 @@
+/*
+ * 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.krpc.internal
+
+import kotlinx.atomicfu.atomic
+import kotlinx.atomicfu.locks.ReentrantLock
+import kotlinx.atomicfu.locks.withLock
+import kotlinx.coroutines.CancellableContinuation
+import kotlinx.coroutines.channels.ClosedSendChannelException
+import kotlinx.coroutines.channels.SendChannel
+import kotlinx.coroutines.channels.onClosed
+import kotlinx.coroutines.channels.onSuccess
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.rpc.krpc.KrpcTransportMessage
+import kotlin.coroutines.resume
+
+internal class KrpcSendHandler(
+ private val sendChannel: SendChannel,
+) {
+ private val closed = atomic(false)
+
+ internal var window = -1
+ private set
+
+ /**
+ * Could've been just `atomic`, but when we send the message, we need to:
+ * - Check size
+ * - Send Message
+ * - Update window size if sent
+ *
+ * As a one atomic operation.
+ *
+ * We are an execution resume guarantee as [kotlinx.coroutines.channels.Channel.trySend] doesn't wait
+ *
+ * TODO contention is very high here, we need to optimize it.
+ * KRPC-211 Optimize KrpcSendHandler
+ */
+ private val windowLock = ReentrantLock()
+ private val continuationsLock = ReentrantLock()
+
+ // internal for tests
+ @Suppress("PropertyName", "detekt.VariableNaming")
+ internal val __continuations = mutableListOf>()
+
+ fun updateWindowSize(update: Int) {
+ if (closed.value) {
+ return
+ }
+
+ windowLock.withLock {
+ window = if (window == -1) {
+ update
+ } else {
+ window + update
+ }
+
+ if (window <= 0) {
+ return
+ }
+
+ continuationsLock.withLock {
+ @Suppress("unused")
+ for (i in 0 until window) {
+ __continuations.removeFirstOrNull()?.resume(Unit)
+ }
+ }
+ }
+ }
+
+ @Suppress("detekt.ThrowsCount")
+ suspend fun sendMessage(message: KrpcTransportMessage) {
+ if (closed.value) {
+ throw ClosedSendChannelException("KrpcSendHandler closed")
+ }
+
+ while (true) {
+ val currentWindow = window
+ if (currentWindow == -1) {
+ sendChannel.trySend(message).onClosed {
+ throw it ?: ClosedSendChannelException("KrpcSendHandler closed")
+ }
+
+ break
+ } else {
+ if (currentWindow == 0) {
+ suspendCancellableCoroutine { cont ->
+ windowLock.withLock {
+ if (window > 0) {
+ cont.resume(Unit)
+ return@withLock
+ }
+
+ continuationsLock.withLock {
+ __continuations.add(cont)
+ }
+ }
+ }
+
+ continue
+ }
+
+ val sent = windowLock.withLock {
+ val currentWindow = window
+
+ if (currentWindow == 0) {
+ return@withLock false
+ }
+
+ if (closed.value) {
+ throw ClosedSendChannelException("KrpcSendHandler closed")
+ }
+
+ val result = sendChannel.trySend(message)
+ .onSuccess {
+ // don't resume other continuations, as we decrement
+ // no if check as we have lock
+ window = currentWindow - 1
+ }
+ .onClosed {
+ throw it ?: ClosedSendChannelException("KrpcSendHandler closed")
+ }
+
+ result.isSuccess
+ }
+
+ if (sent) {
+ break
+ }
+ }
+ }
+ }
+
+ fun close(e: Throwable? = null) {
+ if (closed.compareAndSet(expect = false, update = true)) {
+ continuationsLock.withLock {
+ __continuations.forEach { it.cancel(e) }
+ }
+ }
+ }
+}
diff --git a/krpc/krpc-core/src/commonMain/kotlin/kotlinx/rpc/krpc/internal/KrpcVersion.kt b/krpc/krpc-core/src/commonMain/kotlin/kotlinx/rpc/krpc/internal/KrpcVersion.kt
index fb310ec5e..d3eb84fd8 100644
--- a/krpc/krpc-core/src/commonMain/kotlin/kotlinx/rpc/krpc/internal/KrpcVersion.kt
+++ b/krpc/krpc-core/src/commonMain/kotlin/kotlinx/rpc/krpc/internal/KrpcVersion.kt
@@ -11,4 +11,5 @@ internal enum class KrpcVersion {
V_0_1_0_BETA,
V_0_6_0,
V_0_8_0,
+ V_0_10_0,
}
diff --git a/krpc/krpc-core/src/commonTest/kotlin/kotlinx/rpc/krpc/KrpcConnectorTest.kt b/krpc/krpc-core/src/commonTest/kotlin/kotlinx/rpc/krpc/KrpcConnectorTest.kt
new file mode 100644
index 000000000..1c83e90fe
--- /dev/null
+++ b/krpc/krpc-core/src/commonTest/kotlin/kotlinx/rpc/krpc/KrpcConnectorTest.kt
@@ -0,0 +1,397 @@
+/*
+ * Copyright 2023-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
+ */
+
+@file:OptIn(ExperimentalCoroutinesApi::class)
+
+package kotlinx.rpc.krpc
+
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.cancelAndJoin
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.job
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.TestScope
+import kotlinx.rpc.krpc.internal.HandlerKey
+import kotlinx.rpc.krpc.internal.KrpcCallMessage
+import kotlinx.rpc.krpc.internal.KrpcConnector
+import kotlinx.rpc.krpc.internal.KrpcGenericMessage
+import kotlinx.rpc.krpc.internal.KrpcMessage
+import kotlinx.rpc.krpc.internal.KrpcPlugin
+import kotlinx.rpc.krpc.internal.KrpcPluginKey
+import kotlinx.rpc.krpc.internal.KrpcProtocolMessage
+import kotlinx.rpc.krpc.internal.deserialize
+import kotlinx.rpc.test.runTestWithCoroutinesProbes
+import kotlinx.serialization.json.Json
+import kotlin.coroutines.CoroutineContext
+import kotlin.js.JsName
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+import kotlin.test.assertTrue
+import kotlin.test.fail
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.Duration.Companion.seconds
+
+class KrpcConnectorTest : KrpcConnectorBaseTest() {
+ @Test
+ @JsName("service_call_success_is_delivered_to_a_client")
+ fun `service call success is delivered to a client`() = runTest { client, server ->
+ // Enable backpressure to use configured perCallBufferSize deterministically
+ val clientPExceptionsChannel = Channel(Channel.UNLIMITED)
+ val serverPExceptionsChannel = Channel(Channel.UNLIMITED)
+ val hs = handshakeBoth(client, server, clientPExceptionsChannel, serverPExceptionsChannel)
+
+ val clientInbox = Channel(10)
+
+ client.subscribeToMessages(HandlerKey.ServiceCall("svc", "1")) {
+ clientInbox.send(it)
+ }
+
+ server.subscribeToMessages(HandlerKey.Service("svc")) { message ->
+ if (message is KrpcCallMessage.CallDataString) {
+ server.sendMessage(
+ KrpcCallMessage.CallSuccessString(
+ callId = message.callId,
+ serviceType = message.serviceType,
+ data = "${message.data} : OK",
+ connectionId = message.connectionId,
+ serviceId = message.serviceId,
+ )
+ )
+ }
+ }
+
+ client.sendMessage("ping".asCallMessage("svc", "1"))
+
+ val reply = clientInbox.receive()
+ val success = assertIs(reply)
+ assertEquals("ping : OK", success.data)
+
+ hs.assertAllShookHands()
+
+ assertTrue(clientPExceptionsChannel.isEmpty)
+ assertTrue(serverPExceptionsChannel.isEmpty)
+ }
+
+ @Test
+ @JsName("server_exception_propagates_as_a_call_exception")
+ fun `server exception propagates as a call exception`() = runTest { client, server ->
+ val clientPExceptionsChannel = Channel(Channel.UNLIMITED)
+ val serverPExceptionsChannel = Channel(Channel.UNLIMITED)
+ val hs = handshakeBoth(client, server, clientPExceptionsChannel, serverPExceptionsChannel)
+
+ val clientInbox = Channel(10)
+ client.subscribeToMessages(HandlerKey.ServiceCall("svc", "2")) {
+ clientInbox.send(it)
+ }
+
+ server.subscribeToMessages(HandlerKey.Service("svc")) {
+ throw IllegalArgumentException("boom")
+ }
+
+ server.subscribeToMessages(HandlerKey.Generic) {
+ throw IllegalArgumentException("boom")
+ }
+
+ client.sendMessage("req".asCallMessage("svc", "2"))
+
+ val msg = clientInbox.receive()
+ val ex = assertIs(msg)
+ val t = ex.cause.deserialize()
+ assertTrue(t.message!!.contains("Failed to process call"), t.toString())
+
+ client.sendMessage("req".asGenericMessage())
+ val protocolMessage = clientPExceptionsChannel.receive()
+ val protocolException = assertIs(protocolMessage)
+ assertEquals("req".asGenericMessage(), protocolException.failedMessage)
+
+ hs.assertAllShookHands()
+ }
+
+ @Test
+ @JsName("server_timeout_sends_a_call_exception")
+ fun `server timeout sends a call exception`() = runTest(callTimeout = 200.milliseconds) { client, server ->
+ val clientPExceptionsChannel = Channel(Channel.UNLIMITED)
+ val serverPExceptionsChannel = Channel(Channel.UNLIMITED)
+ val hs = handshakeBoth(client, server, clientPExceptionsChannel, serverPExceptionsChannel)
+
+ val clientInbox = Channel(10)
+ client.subscribeToMessages(HandlerKey.ServiceCall("svc", "3")) {
+ clientInbox.send(it)
+ }
+
+ server.subscribeToMessages(HandlerKey.Service("svc")) {
+ // Simulate long processing exceeding callTimeout
+ delay(500.milliseconds)
+ }
+
+ client.sendMessage("req".asCallMessage("svc", "3"))
+
+ val msg = clientInbox.receive()
+
+ val ex = assertIs(msg)
+ val t = ex.cause.deserialize()
+ assertTrue(t.message!!.contains("Failed to process call"), t.toString())
+ assertEquals(t.cause?.message?.contains("Timeout while processing message"), true, t.toString())
+
+ hs.assertAllShookHands()
+ assertTrue(clientPExceptionsChannel.isEmpty)
+ assertTrue(serverPExceptionsChannel.isEmpty)
+ }
+
+ @Test
+ @JsName("buffer_overflow_does_not_result_in_a_call_exception")
+ fun `buffer overflow does not result in a call exception`() = runTest(perCallBufferSize = 1) { client, server ->
+ val clientPExceptionsChannel = Channel(Channel.UNLIMITED)
+ val serverPExceptionsChannel = Channel(Channel.UNLIMITED)
+ val hs = handshakeBoth(client, server, clientPExceptionsChannel, serverPExceptionsChannel)
+
+ val clientInbox = Channel(10)
+ client.subscribeToMessages(HandlerKey.ServiceCall("svc", "4")) {
+ if (it is KrpcCallMessage.CallException) {
+ val ex = it.cause.deserialize()
+
+ coroutineContext.cancel(CancellationException("Unexpected failure from the server", ex))
+ return@subscribeToMessages
+ }
+
+ clientInbox.send(it)
+ }
+
+ val serverInbox = Channel(10)
+ server.subscribeToMessages(HandlerKey.Service("svc")) {
+ if (it is KrpcCallMessage.CallException) {
+ val ex = it.cause.deserialize()
+
+ coroutineContext.cancel(CancellationException("Unexpected failure from the client", ex))
+ return@subscribeToMessages
+ }
+
+ serverInbox.send(it)
+ }
+
+ client.sendMessage("m1".asCallMessage("svc", "4"))
+ client.sendMessage("m2".asCallMessage("svc", "4"))
+
+ val msg = serverInbox.receive()
+ assertEquals("m1", (msg as KrpcCallMessage.CallDataString).data)
+ val msg2 = serverInbox.receive()
+ assertEquals("m2", (msg2 as KrpcCallMessage.CallDataString).data)
+
+ hs.assertAllShookHands()
+ assertTrue(clientInbox.isEmpty)
+ assertTrue(clientPExceptionsChannel.isEmpty)
+ assertTrue(serverPExceptionsChannel.isEmpty)
+ }
+
+ @Test
+ @JsName("wait_timeout_exceeds_sends_a_call_exception")
+ fun `wait timeout exceeds sends a call exception`() = runTest(
+ waitTimeout = 300.milliseconds,
+ perCallBufferSize = 2,
+ ) { client, server ->
+ val clientPExceptionsChannel = Channel(Channel.UNLIMITED)
+ val serverPExceptionsChannel = Channel(Channel.UNLIMITED)
+ val hs = handshakeBoth(client, server, clientPExceptionsChannel, serverPExceptionsChannel)
+
+ val clientInbox = Channel(10)
+ client.subscribeToMessages(HandlerKey.ServiceCall("svc", "5")) {
+ clientInbox.send(it)
+ }
+
+ // Send one message that will stay unprocessed until waitTimeout triggers discard
+ client.sendMessage("stay".asCallMessage("svc", "5"))
+ client.sendMessage("stay2".asCallMessage("svc", "5"))
+
+ val msg = clientInbox.receive()
+ val ex = assertIs(msg)
+ val top = ex.cause.deserialize()
+ // top-level message comes from buffer close; nested cause contains a wait-timeout message
+ assertEquals(top.message?.contains("2 messages were unprocessed"), true, top.toString())
+ assertEquals(top.cause?.message?.contains("Waiting limit of"), true, top.toString())
+
+ hs.assertAllShookHands()
+ }
+
+ @Test
+ @JsName("one_item_buffer_size_works_with_a_stream_of_messages")
+ fun `one item buffer size works with a stream of messages`() = runTest(perCallBufferSize = 1) { client, server ->
+ val clientPExceptionsChannel = Channel(Channel.UNLIMITED)
+ val serverPExceptionsChannel = Channel(Channel.UNLIMITED)
+ val hs = handshakeBoth(client, server, clientPExceptionsChannel, serverPExceptionsChannel)
+
+ val clientInbox = Channel(10)
+ client.subscribeToMessages(HandlerKey.ServiceCall("svc", "6")) {
+ clientInbox.send(it)
+ }
+
+ server.subscribeToMessages(HandlerKey.Service("svc")) { message ->
+ server.sendMessage(message)
+ }
+
+ launch {
+ repeat(100) {
+ client.sendMessage("ping".asCallMessage("svc", "6"))
+ }
+ }
+
+ repeat(100) {
+ assertEquals("ping", (clientInbox.receive() as KrpcCallMessage.CallDataString).data)
+ }
+
+ hs.assertAllShookHands()
+ }
+
+ private class HsResult {
+ val clientShookHands = CompletableDeferred()
+ val serverShookHands = CompletableDeferred()
+
+ fun assertAllShookHands() {
+ assertTrue(clientShookHands.isCompleted)
+ assertTrue(serverShookHands.isCompleted)
+ }
+
+ suspend fun await() {
+ clientShookHands.await()
+ serverShookHands.await()
+ }
+ }
+
+ private suspend fun handshakeBoth(
+ client: KrpcConnector,
+ server: KrpcConnector,
+ clientExceptionsChannel: Channel,
+ serverExceptionsChannel: Channel,
+ ): HsResult {
+ val hs = KrpcProtocolMessage.Handshake(KrpcPlugin.ALL)
+ val hsResult = HsResult()
+
+ client.subscribeToMessages(HandlerKey.Protocol) {
+ if (it is KrpcProtocolMessage.Failure) {
+ if (!hsResult.clientShookHands.isCompleted) {
+ fail("Handshake must be first message, but got: $it")
+ }
+
+ clientExceptionsChannel.send(it)
+ }
+ assertEquals(hs, it)
+ hsResult.clientShookHands.complete(Unit)
+ }
+
+ server.subscribeToMessages(HandlerKey.Protocol) {
+ if (it is KrpcProtocolMessage.Failure) {
+ if (!hsResult.serverShookHands.isCompleted) {
+ fail("Handshake must be first message, but got: $it")
+ }
+
+ serverExceptionsChannel.send(it)
+ }
+ assertEquals(hs, it)
+ hsResult.serverShookHands.complete(Unit)
+ }
+
+ client.sendMessage(hs)
+ server.sendMessage(hs)
+
+ hsResult.await()
+
+ return hsResult
+ }
+}
+
+abstract class KrpcConnectorBaseTest {
+ protected fun String.asCallMessage(
+ serviceId: String,
+ callId: String,
+ ) = KrpcCallMessage.CallDataString(
+ connectionId = null,
+ callId = callId,
+ serviceType = serviceId,
+ data = this,
+ callableName = "",
+ callType = KrpcCallMessage.CallType.Method,
+ )
+
+ protected fun String.asGenericMessage() = KrpcGenericMessage(
+ connectionId = null,
+ pluginParams = mapOf(KrpcPluginKey.GENERIC_MESSAGE_TYPE to this),
+ )
+
+ protected fun runTest(
+ testTimeout: Duration = 15.seconds,
+ waitTimeout: Duration = 1.seconds,
+ callTimeout: Duration = 1.seconds,
+ perCallBufferSize: Int = 100,
+ body: suspend TestScope.(clientConnector: KrpcConnector, serverConnector: KrpcConnector) -> Unit,
+ ) = runTestWithCoroutinesProbes(timeout = testTimeout) {
+ val connectorConfig = KrpcConfig.Connector(waitTimeout, callTimeout, perCallBufferSize)
+
+ val transport = LocalTransport(coroutineContext)
+
+ val clientConnector = KrpcConnector(
+ serialFormat = Json,
+ transport = transport.client,
+ config = connectorConfig,
+ isServer = false,
+ )
+
+ val serverConnector = KrpcConnector(
+ serialFormat = Json,
+ transport = transport.server,
+ config = connectorConfig,
+ isServer = true,
+ )
+
+ try {
+ body(clientConnector, serverConnector)
+ } finally {
+ transport.coroutineContext.job.cancelAndJoin()
+ }
+ }
+}
+
+private class LocalTransport(
+ parentContext: CoroutineContext? = null,
+) : CoroutineScope {
+ override val coroutineContext = SupervisorJob(parentContext?.get(Job))
+
+ private val clientIncoming = Channel()
+ private val serverIncoming = Channel()
+
+ val client: KrpcTransport = object : KrpcTransport {
+ override val coroutineContext: CoroutineContext = Job(this@LocalTransport.coroutineContext.job).let {
+ if (parentContext != null) parentContext + it else it
+ }
+
+ override suspend fun send(message: KrpcTransportMessage) {
+ serverIncoming.send(message)
+ }
+
+ override suspend fun receive(): KrpcTransportMessage {
+ return clientIncoming.receive()
+ }
+ }
+
+ val server: KrpcTransport = object : KrpcTransport {
+ override val coroutineContext: CoroutineContext = Job(this@LocalTransport.coroutineContext.job).let {
+ if (parentContext != null) parentContext + it else it
+ }
+
+ override suspend fun send(message: KrpcTransportMessage) {
+ clientIncoming.send(message)
+ }
+
+ override suspend fun receive(): KrpcTransportMessage {
+ return serverIncoming.receive()
+ }
+ }
+}
diff --git a/krpc/krpc-core/src/commonTest/kotlin/kotlinx/rpc/krpc/KrpcReceiveBufferTest.kt b/krpc/krpc-core/src/commonTest/kotlin/kotlinx/rpc/krpc/KrpcReceiveBufferTest.kt
new file mode 100644
index 000000000..ac0b9a0e4
--- /dev/null
+++ b/krpc/krpc-core/src/commonTest/kotlin/kotlinx/rpc/krpc/KrpcReceiveBufferTest.kt
@@ -0,0 +1,85 @@
+/*
+ * 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.krpc
+
+import kotlinx.coroutines.test.TestScope
+import kotlinx.rpc.krpc.internal.BufferResult
+import kotlinx.rpc.krpc.internal.KrpcGenericMessage
+import kotlinx.rpc.krpc.internal.KrpcPluginKey
+import kotlinx.rpc.krpc.internal.KrpcReceiveBuffer
+import kotlinx.rpc.krpc.internal.isClosed
+import kotlinx.rpc.krpc.internal.isFailure
+import kotlinx.rpc.krpc.internal.isSuccess
+import kotlinx.rpc.test.runTestWithCoroutinesProbes
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
+
+internal class KrpcReceiveBufferTest : KrpcReceiveBufferBaseTest() {
+ @Test
+ fun zeroBufferSize() = runTest(bufferSize = 0) { buffer ->
+ val result = buffer.trySend("hello")
+
+ assertTrue { result.isFailure }
+ assertEquals(0, buffer.window)
+ assertEquals(0, buffer.inBuffer)
+ }
+
+ @Test
+ fun oneBufferSize() = runTest(bufferSize = 1) { buffer ->
+ assertEquals(1, buffer.window)
+ val result = buffer.trySend("hello")
+
+ assertTrue { result.isSuccess }
+ assertEquals(0, buffer.window)
+ assertEquals(1, buffer.inBuffer)
+
+ val secondResult = buffer.trySend("world")
+ assertTrue { secondResult.isFailure }
+ }
+
+ @Test
+ fun closeBuffer() = runTest(bufferSize = 3) { buffer ->
+ assertEquals(3, buffer.window)
+ buffer.trySend("hello")
+ buffer.trySend("world")
+ buffer.close(null)
+ assertEquals(3, buffer.inBuffer)
+ assertEquals(0, buffer.window)
+ val result = buffer.trySend("test")
+ assertTrue { result.isClosed }
+ }
+}
+
+internal abstract class KrpcReceiveBufferBaseTest {
+ protected fun KrpcReceiveBuffer.trySend(
+ message: String,
+ ): BufferResult {
+ return trySend(
+ KrpcReceiveBuffer.MessageRequest(
+ message = KrpcGenericMessage(null, mapOf(KrpcPluginKey.WINDOW_KEY to message)),
+ onMessageFailure = { },
+ )
+ )
+ }
+
+ protected fun runTest(
+ bufferSize: Int,
+ timeout: Duration = 10.seconds,
+ body: suspend TestScope.(KrpcReceiveBuffer) -> Unit,
+ ) = runTestWithCoroutinesProbes(timeout = timeout) {
+ val buffer = KrpcReceiveBuffer(
+ bufferSize = { bufferSize },
+ )
+
+ try {
+ body(buffer)
+ } finally {
+ buffer.close(null)
+ }
+ }
+}
diff --git a/krpc/krpc-core/src/commonTest/kotlin/kotlinx/rpc/krpc/KrpcReceiveHandlerTest.kt b/krpc/krpc-core/src/commonTest/kotlin/kotlinx/rpc/krpc/KrpcReceiveHandlerTest.kt
new file mode 100644
index 000000000..0806ba99f
--- /dev/null
+++ b/krpc/krpc-core/src/commonTest/kotlin/kotlinx/rpc/krpc/KrpcReceiveHandlerTest.kt
@@ -0,0 +1,294 @@
+/*
+ * 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.krpc
+
+import kotlinx.atomicfu.atomic
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancelAndJoin
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.job
+import kotlinx.coroutines.joinAll
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.TestResult
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.yield
+import kotlinx.rpc.krpc.internal.HandlerKey
+import kotlinx.rpc.krpc.internal.KrpcActingReceiveHandler
+import kotlinx.rpc.krpc.internal.KrpcCallMessage
+import kotlinx.rpc.krpc.internal.KrpcCallMessage.CallType
+import kotlinx.rpc.krpc.internal.KrpcGenericMessage
+import kotlinx.rpc.krpc.internal.KrpcMessage
+import kotlinx.rpc.krpc.internal.KrpcMessageSender
+import kotlinx.rpc.krpc.internal.KrpcMessageSubscription
+import kotlinx.rpc.krpc.internal.KrpcPluginKey
+import kotlinx.rpc.krpc.internal.KrpcReceiveBuffer
+import kotlinx.rpc.krpc.internal.KrpcSendHandler
+import kotlinx.rpc.krpc.internal.KrpcStoringReceiveHandler
+import kotlinx.rpc.krpc.internal.WindowResult
+import kotlinx.rpc.krpc.internal.decodeWindow
+import kotlinx.rpc.krpc.internal.deserialize
+import kotlinx.rpc.krpc.internal.isFailure
+import kotlinx.rpc.krpc.internal.isSuccess
+import kotlinx.rpc.krpc.internal.onClosed
+import kotlinx.rpc.krpc.internal.onFailure
+import kotlinx.rpc.test.runTestWithCoroutinesProbes
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+import kotlin.test.fail
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
+
+internal class KrpcReceiveHandlerTest : KrpcReceiveHandlerBaseTest() {
+ @Test
+ fun zeroBufferSizeStoring() = runStoringTest(bufferSize = 0) {
+ val result = storing.handle("test".asMessage()) {}
+
+ assertTrue { result.isFailure }
+ }
+
+ @Test
+ fun windowUpdate() = runActingTest(
+ callTimeOut = 10.seconds,
+ bufferSize = 0,
+ callHandler = { },
+ ) { acting ->
+ acting.broadcastWindowUpdate(1, null, "service", "callId")
+
+ val windowResult = decodeWindow(channel.receive() as KrpcGenericMessage)
+
+ assertTrue(windowResult is WindowResult.Success, windowResult.toString())
+ assertEquals(1, windowResult.update)
+ assertEquals("service", windowResult.key.serviceType)
+ assertEquals("callId", windowResult.key.callId)
+
+ acting.broadcastWindowUpdate(-1, null, "service", "callId")
+ val windowResult2 = decodeWindow(channel.receive() as KrpcGenericMessage)
+ assertEquals(-1, (windowResult2 as WindowResult.Success).update)
+
+ acting.broadcastWindowUpdate(Int.MAX_VALUE, null, "service", "callId")
+ val windowResult3 = decodeWindow(channel.receive() as KrpcGenericMessage)
+ assertEquals(Int.MAX_VALUE, (windowResult3 as WindowResult.Success).update)
+
+ acting.broadcastWindowUpdate(Int.MIN_VALUE, null, "service", "callId")
+ val windowResult4 = decodeWindow(channel.receive() as KrpcGenericMessage)
+ assertEquals(Int.MIN_VALUE, (windowResult4 as WindowResult.Success).update)
+ }
+
+ @Test
+ fun oneBufferSizeStoring() = runStoringTest(bufferSize = 1) {
+ val result = storing.handle("test".asMessage()) {}
+
+ assertTrue { result.isSuccess }
+
+ val result2 = storing.handle("test".asMessage()) {}
+
+ assertTrue { result2.isFailure }
+ }
+
+ @Test
+ fun multipleWindowSizeStoring() = runStoringTest(bufferSize = 3) {
+ storing.handle("test1".asCallMessage("1")) {}
+ storing.handle("test2".asCallMessage("1")) {}
+ storing.handle("test3".asCallMessage("2")) {}
+
+ storing.close(HandlerKey.Generic, null)
+
+ val message1 = (channel.receive() as KrpcCallMessage.CallException).cause.deserialize().message.orEmpty()
+ assertTrue(message1.contains("2 messages were unprocessed"), message1)
+
+ val message2 = (channel.receive() as KrpcCallMessage.CallException).cause.deserialize().message.orEmpty()
+ assertTrue(message2.contains("1 messages were unprocessed"), message2)
+ }
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ @Test
+ fun stressActing() {
+ val actorJob = Job()
+ val collected = mutableListOf()
+ val bufferSize = stressBufferSize
+
+ runActingTest(
+ callTimeOut = 10.seconds,
+ bufferSize = bufferSize,
+ callHandler = { collected.add(it) },
+ timeout = 360.seconds,
+ ) { acting ->
+ val sendChannel = Channel(Channel.UNLIMITED)
+ val sender = KrpcSendHandler(sendChannel)
+ sender.updateWindowSize(bufferSize)
+
+ val windowJob = launch {
+ while (true) {
+ val window = when (val message = channel.receive()) {
+ is KrpcCallMessage.CallException -> fail(
+ "Unexpected call exception",
+ message.cause.deserialize()
+ )
+
+ is KrpcGenericMessage -> decodeWindow(message)
+ else -> fail("Unexpected message: $message")
+ }
+
+ sender.updateWindowSize((window as WindowResult.Success).update)
+ }
+ }
+
+ val senderJob = launch {
+ while (true) {
+ val message = sendChannel.receive() as KrpcTransportMessage.StringMessage
+
+ acting.handle(message.value.asCallMessage("1")) {
+ fail(
+ "Unexpected onMessageFailure call, " +
+ "window: ${sender.window}, collected: ${collected.size}\"",
+ it
+ )
+ }.onFailure {
+ fail(
+ "Unexpected onFailure call, " +
+ "window: ${sender.window}, collected: ${collected.size}"
+ )
+ }.onClosed {
+ fail(
+ "Unexpected onClosed call, " +
+ "window: ${sender.window}, collected: ${collected.size}\"",
+ it
+ )
+ }
+ }
+ }
+
+ val counter = Counter()
+ val printJob = launch {
+ while (true) {
+ withContext(Dispatchers.Default) {
+ delay(5.seconds)
+ }
+ println("Collected: ${collected.size}, launches: ${counter.launches.value}, total: ${counter.total.value}")
+ }
+ }
+
+ val iterations = stressIterations
+ List(iterations) {
+ launch {
+ repeat(100) {
+ sender.sendMessage(KrpcTransportMessage.StringMessage("Hello"))
+ counter.total.incrementAndGet()
+ }
+ counter.launches.incrementAndGet()
+ }
+ }.joinAll()
+
+ while (!buffer.channel.isEmpty && sender.window != bufferSize) {
+ yield()
+ }
+
+ assertEquals(iterations * 100, collected.size)
+ actorJob.cancelAndJoin()
+ senderJob.cancelAndJoin()
+ windowJob.cancelAndJoin()
+ printJob.cancelAndJoin()
+ }
+ }
+}
+
+internal abstract class KrpcReceiveHandlerBaseTest {
+ class Counter {
+ val launches = atomic(0)
+ val total = atomic(0)
+ }
+
+ protected fun String.asMessage() = KrpcGenericMessage(
+ connectionId = null,
+ pluginParams = mapOf(KrpcPluginKey.WINDOW_KEY to this)
+ )
+
+ protected fun String.asCallMessage(callId: String) = KrpcCallMessage.CallDataString(
+ connectionId = null,
+ callId = callId,
+ serviceType = "service",
+ data = this,
+ callableName = "",
+ callType = CallType.Method,
+ )
+
+ class TestConfig(
+ val storing: KrpcStoringReceiveHandler,
+ val buffer: KrpcReceiveBuffer,
+ val channel: Channel,
+ val sender: KrpcMessageSender,
+ val testScope: TestScope,
+ ) : CoroutineScope by testScope
+
+ protected fun runActingTest(
+ callTimeOut: Duration,
+ bufferSize: Int,
+ callHandler: KrpcMessageSubscription,
+ timeout: Duration = 30.seconds,
+ body: suspend TestConfig.(acting: KrpcActingReceiveHandler) -> Unit,
+ ): TestResult {
+ return runStoringTest(bufferSize, timeout) {
+ val acting = KrpcActingReceiveHandler(
+ callHandler = callHandler,
+ storingHandler = storing,
+ sender = sender,
+ key = HandlerKey.Generic,
+ timeout = callTimeOut,
+ broadcastUpdates = true,
+ )
+
+ body(this, acting)
+
+ acting.close(HandlerKey.Generic, null)
+ }
+ }
+
+ protected fun runStoringTest(
+ bufferSize: Int,
+ timeout: Duration = 30.seconds,
+ body: suspend TestConfig.() -> Unit,
+ ) = runTestWithCoroutinesProbes(timeout = timeout) {
+ val buffer = KrpcReceiveBuffer(
+ bufferSize = { bufferSize },
+ )
+
+ val channel = Channel(Channel.UNLIMITED)
+
+ val senderJob = Job(this@runTestWithCoroutinesProbes.coroutineContext.job)
+ val sender = object : KrpcMessageSender {
+ override suspend fun sendMessage(message: KrpcMessage) {
+ channel.send(message)
+ }
+
+ override val transportScope: CoroutineScope = CoroutineScope(senderJob)
+
+ override fun drainSendQueueAndClose(message: String) {
+ TODO("Not yet implemented")
+ }
+ }
+
+ val handler = KrpcStoringReceiveHandler(buffer, sender)
+ val config = TestConfig(handler, buffer, channel, sender, this)
+
+ try {
+ body(config)
+ } finally {
+ handler.close(HandlerKey.Generic, null)
+ buffer.close(null)
+ channel.cancel()
+ channel.close()
+ senderJob.cancelAndJoin()
+ }
+ }
+}
+
+internal expect val stressIterations: Int
+internal expect val stressBufferSize: Int
diff --git a/krpc/krpc-core/src/commonTest/kotlin/kotlinx/rpc/krpc/KrpcSendHandlerTest.kt b/krpc/krpc-core/src/commonTest/kotlin/kotlinx/rpc/krpc/KrpcSendHandlerTest.kt
new file mode 100644
index 000000000..759c276e4
--- /dev/null
+++ b/krpc/krpc-core/src/commonTest/kotlin/kotlinx/rpc/krpc/KrpcSendHandlerTest.kt
@@ -0,0 +1,133 @@
+/*
+ * 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.krpc
+
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.joinAll
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.withTimeoutOrNull
+import kotlinx.coroutines.yield
+import kotlinx.rpc.krpc.internal.KrpcSendHandler
+import kotlinx.rpc.test.runTestWithCoroutinesProbes
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.fail
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
+
+internal class KrpcSendHandlerTest : KrpcSendHandlerBaseTest() {
+ @Test
+ fun zeroWindowSize() = runTest { _, handler ->
+ handler.updateWindowSize(0)
+
+ val job = launch {
+ var cancelled = false
+
+ try {
+ handler.sendMessage("Hello".asMessage())
+ } catch (e: CancellationException) {
+ cancelled = e.message?.contains("Test finished") ?: false
+ if (!cancelled) {
+ fail("Unexpected cancellation exception", e)
+ }
+
+ throw e
+ }
+
+ if (!cancelled) {
+ fail("Expected cancellation exception")
+ }
+ }
+
+ handler.awaitSenders(1)
+
+ handler.close(CancellationException("Test finished"))
+ job.join()
+ }
+
+ @Test
+ fun oneWindowSize() = runTest { channel, handler ->
+ handler.updateWindowSize(0)
+
+ val job = launch {
+ handler.sendMessage("Hello".asMessage())
+ }
+
+ handler.awaitSenders(1)
+
+ handler.updateWindowSize(1)
+
+ job.join()
+
+ assertEquals("Hello", channel.receive().value)
+ }
+
+ @Test
+ fun multipleWindowSize() = runTest { channel, handler ->
+ handler.updateWindowSize(0)
+
+ val jobs = List(3) {
+ launch {
+ handler.sendMessage("Hello".asMessage())
+ }
+ }
+
+ handler.awaitSenders(3)
+
+ handler.updateWindowSize(1)
+
+ assertEquals("Hello", channel.receive().value)
+
+ handler.awaitSenders(2)
+
+ handler.updateWindowSize(2)
+
+ assertEquals("Hello", channel.receive().value)
+ assertEquals("Hello", channel.receive().value)
+
+ jobs.joinAll()
+
+ handler.updateWindowSize(1)
+ handler.sendMessage("Hello".asMessage())
+ assertEquals("Hello", channel.receive().value)
+ }
+}
+
+internal abstract class KrpcSendHandlerBaseTest {
+ protected suspend fun KrpcSendHandler.awaitSenders(num: Int) {
+ withTimeoutOrNull(10.seconds) {
+ while (true) {
+ if (__continuations.size == num) {
+ break
+ }
+
+ yield()
+ }
+ } ?: fail("Timeout while waiting for continuation, current size: ${__continuations.size}")
+ }
+
+ protected fun runTest(
+ timeout: Duration = 10.seconds,
+ body: suspend TestScope.(Channel, KrpcSendHandler) -> Unit,
+ ) = runTestWithCoroutinesProbes(timeout = timeout) {
+ val channel = Channel(
+ capacity = Channel.UNLIMITED,
+ )
+
+ val handler = KrpcSendHandler(channel)
+ try {
+ body(channel, handler)
+ } finally {
+ handler.close()
+ channel.cancel()
+ channel.close()
+ }
+ }
+}
+
+val KrpcTransportMessage.value get() = (this as KrpcTransportMessage.StringMessage).value
+fun String.asMessage() = KrpcTransportMessage.StringMessage(this)
diff --git a/krpc/krpc-core/src/jsMain/kotlin/kotlinx/rpc/krpc/internal/ExceptionUtils.js.kt b/krpc/krpc-core/src/jsMain/kotlin/kotlinx/rpc/krpc/internal/ExceptionUtils.js.kt
index a3a1017b6..030017bfa 100644
--- a/krpc/krpc-core/src/jsMain/kotlin/kotlinx/rpc/krpc/internal/ExceptionUtils.js.kt
+++ b/krpc/krpc-core/src/jsMain/kotlin/kotlinx/rpc/krpc/internal/ExceptionUtils.js.kt
@@ -1,13 +1,11 @@
/*
- * Copyright 2023-2024 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
+ * Copyright 2023-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/
@file:Suppress("detekt.MatchingDeclarationName")
package kotlinx.rpc.krpc.internal
-import kotlinx.rpc.internal.utils.InternalRpcApi
-
internal actual class DeserializedException actual constructor(
private val toStringMessage: String,
actual override val message: String,
@@ -24,7 +22,7 @@ internal actual class DeserializedException actual constructor(
internal actual fun Throwable.stackElements(): List = emptyList()
-@InternalRpcApi
-public actual fun SerializedException.deserialize(): Throwable {
- return DeserializedException(toStringMessage, message, stacktrace, cause, className)
+internal actual fun SerializedException.deserializeUnsafe(): Throwable {
+ return nonJvmManualCancellationExceptionDeserialize()
+ ?: DeserializedException(toStringMessage, message, stacktrace, cause, className)
}
diff --git a/krpc/krpc-core/src/jsTest/kotlin/kotlinx/rpc/krpc/KrpcReceiveHandlerTest.js.kt b/krpc/krpc-core/src/jsTest/kotlin/kotlinx/rpc/krpc/KrpcReceiveHandlerTest.js.kt
new file mode 100644
index 000000000..8b39cd68f
--- /dev/null
+++ b/krpc/krpc-core/src/jsTest/kotlin/kotlinx/rpc/krpc/KrpcReceiveHandlerTest.js.kt
@@ -0,0 +1,8 @@
+/*
+ * 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.krpc
+
+internal actual val stressIterations: Int = 3000
+internal actual val stressBufferSize: Int = 1500
diff --git a/krpc/krpc-core/src/jvmMain/kotlin/kotlinx/rpc/krpc/internal/ExceptionUtils.jvm.kt b/krpc/krpc-core/src/jvmMain/kotlin/kotlinx/rpc/krpc/internal/ExceptionUtils.jvm.kt
index 53ccb4640..9c2341324 100644
--- a/krpc/krpc-core/src/jvmMain/kotlin/kotlinx/rpc/krpc/internal/ExceptionUtils.jvm.kt
+++ b/krpc/krpc-core/src/jvmMain/kotlin/kotlinx/rpc/krpc/internal/ExceptionUtils.jvm.kt
@@ -1,10 +1,9 @@
/*
- * Copyright 2023-2024 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
+ * 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.krpc.internal
-import kotlinx.rpc.internal.utils.InternalRpcApi
import java.lang.reflect.Constructor
import java.lang.reflect.Modifier
@@ -37,8 +36,7 @@ internal actual fun Throwable.stackElements(): List = stackTrace.m
)
}
-@InternalRpcApi
-public actual fun SerializedException.deserialize(): Throwable {
+internal actual fun SerializedException.deserializeUnsafe(): Throwable {
try {
val clazz = Class.forName(className)
val fieldsCount = clazz.fieldsCountOrDefault(throwableFields)
diff --git a/krpc/krpc-core/src/jvmTest/kotlin/kotlinx/rpc/krpc/KrpcReceiveHandlerTest.jvm.kt b/krpc/krpc-core/src/jvmTest/kotlin/kotlinx/rpc/krpc/KrpcReceiveHandlerTest.jvm.kt
new file mode 100644
index 000000000..e348e5539
--- /dev/null
+++ b/krpc/krpc-core/src/jvmTest/kotlin/kotlinx/rpc/krpc/KrpcReceiveHandlerTest.jvm.kt
@@ -0,0 +1,8 @@
+/*
+ * 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.krpc
+
+internal actual val stressIterations: Int = 10_000
+internal actual val stressBufferSize: Int = 500
diff --git a/krpc/krpc-core/src/jvmTest/kotlin/kotlinx/rpc/krpc/KrpcSendHandlerLincheckTest.kt b/krpc/krpc-core/src/jvmTest/kotlin/kotlinx/rpc/krpc/KrpcSendHandlerLincheckTest.kt
new file mode 100644
index 000000000..5418efe79
--- /dev/null
+++ b/krpc/krpc-core/src/jvmTest/kotlin/kotlinx/rpc/krpc/KrpcSendHandlerLincheckTest.kt
@@ -0,0 +1,117 @@
+/*
+ * 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.krpc
+
+import kotlinx.coroutines.CancellableContinuation
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.channels.ClosedSendChannelException
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.rpc.krpc.internal.KrpcSendHandler
+import org.jetbrains.lincheck.datastructures.IntGen
+import org.jetbrains.lincheck.datastructures.ModelCheckingOptions
+import org.jetbrains.lincheck.datastructures.Operation
+import org.jetbrains.lincheck.datastructures.Param
+import org.jetbrains.lincheck.datastructures.StressOptions
+import kotlin.coroutines.resume
+import kotlin.test.Ignore
+import kotlin.test.Test
+
+@Suppress("unused")
+@Param(name = "update", gen = IntGen::class, conf = "1:3")
+class KrpcSendHandlerLincheckTest {
+ private val channel = Channel(Channel.UNLIMITED)
+ private val handler = KrpcSendHandler(channel)
+
+ @Operation
+ fun updateWindow(
+ @Param(name = "update") update: Int,
+ ) = handler.updateWindowSize(update)
+
+ @Operation
+ suspend fun send(message: String): Unit = handler.sendMessage(message.asMessage())
+
+ @Operation
+ fun close() = handler.close()
+
+ @Operation
+ suspend fun receive(): String = channel.receive().value
+
+ @Test
+ fun modelTest() = ModelCheckingOptions()
+ .actorsBefore(2)
+ .threads(5)
+ .actorsPerThread(4)
+ .actorsAfter(2)
+ .iterations(100)
+ .invocationsPerIteration(100)
+// .checkObstructionFreedom(true) // todo we are not lock free
+ .minimizeFailedScenario(true)
+ .sequentialSpecification(SequentialKrpcSendHandler::class.java)
+ .check(this::class)
+
+ @Test
+ @Ignore
+ fun stressTest() = StressOptions()
+ .actorsBefore(2)
+ .threads(3)
+ .actorsPerThread(6)
+ .actorsAfter(2)
+ .iterations(10) // 10 is a magic number here, 11 hangs
+ .invocationsPerIteration(1000)
+ .minimizeFailedScenario(true)
+ .sequentialSpecification(SequentialKrpcSendHandler::class.java)
+ .check(this::class)
+}
+
+@Suppress("unused")
+class SequentialKrpcSendHandler {
+ private var closed = false
+ private val channel = Channel(Channel.UNLIMITED)
+ private var window: Int = -1
+ private val continuations = mutableListOf>()
+
+ fun updateWindow(update: Int) {
+ if (closed) {
+ return
+ }
+
+ window = if (window == -1) update else window + update
+
+ continuations.forEach { it.resume(Unit) }
+ continuations.clear()
+ }
+
+ suspend fun send(message: String) {
+ if (closed) {
+ throw ClosedSendChannelException("KrpcSendHandler closed")
+ }
+
+ val window = window
+ if (window == -1) {
+ channel.send(message)
+ return
+ }
+
+ while (true) {
+ if (window == 0) {
+ suspendCancellableCoroutine { cont ->
+ continuations.add(cont)
+ }
+ } else {
+ channel.send(message)
+ updateWindow(-1)
+ break
+ }
+ }
+ }
+
+ fun close() {
+ closed = true
+ }
+
+ suspend fun receive(): String {
+ return channel.receive()
+ }
+}
diff --git a/krpc/krpc-core/src/jvmTest/kotlin/kotlinx/rpc/krpc/KrpcSendHandlerStressTest.kt b/krpc/krpc-core/src/jvmTest/kotlin/kotlinx/rpc/krpc/KrpcSendHandlerStressTest.kt
new file mode 100644
index 000000000..13b2d5e42
--- /dev/null
+++ b/krpc/krpc-core/src/jvmTest/kotlin/kotlinx/rpc/krpc/KrpcSendHandlerStressTest.kt
@@ -0,0 +1,114 @@
+/*
+ * 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.krpc
+
+import kotlinx.coroutines.ExecutorCoroutineDispatcher
+import kotlinx.coroutines.asCoroutineDispatcher
+import kotlinx.coroutines.launch
+import java.time.Instant
+import java.util.concurrent.Executors
+import kotlin.random.Random
+import kotlin.test.Test
+import kotlin.time.Duration.Companion.seconds
+
+internal class KrpcSendHandlerStressTest : KrpcSendHandlerBaseTest() {
+ @Test
+ fun stressNoWindow() = runTest { channel, handler ->
+ executorDispatcher().use { dispatcher ->
+ repeat(100_000) {
+ launch(dispatcher) {
+ handler.sendMessage("Hello".asMessage())
+ }
+ }
+
+ launch {
+ repeat(100_000) {
+ channel.receive()
+ }
+ }.join()
+ }
+ }
+
+ @Test
+ fun stressOpenWindow() = runTest { channel, handler ->
+ executorDispatcher().use { dispatcher ->
+ handler.updateWindowSize(100_000)
+
+ repeat(100_000) {
+ launch(dispatcher) {
+ handler.sendMessage("Hello".asMessage())
+ }
+ }
+
+ launch {
+ repeat(100_000) {
+ channel.receive()
+ }
+ }.join()
+ }
+ }
+
+ @Test
+ fun stressOpenWindowWithWait() = runTest { channel, handler ->
+ executorDispatcher().use { dispatcher ->
+ handler.updateWindowSize(0)
+
+ repeat(100_000) {
+ launch(dispatcher) {
+ handler.sendMessage("Hello".asMessage())
+ }
+ }
+
+ val collector = launch {
+ repeat(100_000) {
+ channel.receive()
+ }
+ }
+
+ handler.awaitSenders(100_000)
+
+ handler.updateWindowSize(100_000)
+
+ collector.join()
+ }
+ }
+
+ @Test
+ fun stressRandomWindow() = runTest(timeout = 30.seconds) { channel, handler ->
+ executorDispatcher().use { dispatcher ->
+ handler.updateWindowSize(0)
+
+ repeat(100_000) {
+ launch(dispatcher) {
+ handler.sendMessage("Hello".asMessage())
+ }
+ }
+
+ val collector = launch {
+ repeat(100_000) {
+ channel.receive()
+ }
+ }
+
+ handler.awaitSenders(100_000)
+
+ var processed = 0
+ val random = Random(Instant.now().toEpochMilli())
+ while (processed < 100_000) {
+ val nextUpdate = random.nextInt(500, 2000)
+ handler.updateWindowSize(nextUpdate)
+ handler.awaitSenders((100_000 - processed - nextUpdate).coerceAtLeast(0))
+ println("Processed $processed")
+ processed += nextUpdate
+ }
+
+ collector.join()
+ }
+ }
+
+ private fun executorDispatcher(): ExecutorCoroutineDispatcher = Executors
+ .newFixedThreadPool(Runtime.getRuntime().availableProcessors() * 2)
+ .asCoroutineDispatcher()
+}
diff --git a/krpc/krpc-core/src/nativeMain/kotlin/kotlinx/rpc/krpc/internal/ExceptionUtils.native.kt b/krpc/krpc-core/src/nativeMain/kotlin/kotlinx/rpc/krpc/internal/ExceptionUtils.native.kt
index 989c31df2..07abac13c 100644
--- a/krpc/krpc-core/src/nativeMain/kotlin/kotlinx/rpc/krpc/internal/ExceptionUtils.native.kt
+++ b/krpc/krpc-core/src/nativeMain/kotlin/kotlinx/rpc/krpc/internal/ExceptionUtils.native.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2023-2024 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
+ * Copyright 2023-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/
@file:Suppress("detekt.MatchingDeclarationName")
@@ -21,6 +21,7 @@ internal actual class DeserializedException actual constructor(
internal actual fun Throwable.stackElements(): List = emptyList()
-public actual fun SerializedException.deserialize(): Throwable {
- return DeserializedException(toStringMessage, message, stacktrace, cause, className)
+internal actual fun SerializedException.deserializeUnsafe(): Throwable {
+ return nonJvmManualCancellationExceptionDeserialize()
+ ?: DeserializedException(toStringMessage, message, stacktrace, cause, className)
}
diff --git a/krpc/krpc-core/src/nativeTest/kotlin/kotlinx/rpc/krpc/KrpcReceiveHandlerTest.native.kt b/krpc/krpc-core/src/nativeTest/kotlin/kotlinx/rpc/krpc/KrpcReceiveHandlerTest.native.kt
new file mode 100644
index 000000000..8b39cd68f
--- /dev/null
+++ b/krpc/krpc-core/src/nativeTest/kotlin/kotlinx/rpc/krpc/KrpcReceiveHandlerTest.native.kt
@@ -0,0 +1,8 @@
+/*
+ * 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.krpc
+
+internal actual val stressIterations: Int = 3000
+internal actual val stressBufferSize: Int = 1500
diff --git a/krpc/krpc-core/src/wasmJsMain/kotlin/kotlinx/rpc/krpc/internal/ExceptionUtils.wasm.kt b/krpc/krpc-core/src/wasmJsMain/kotlin/kotlinx/rpc/krpc/internal/ExceptionUtils.wasm.kt
index a3a1017b6..8acc01ea0 100644
--- a/krpc/krpc-core/src/wasmJsMain/kotlin/kotlinx/rpc/krpc/internal/ExceptionUtils.wasm.kt
+++ b/krpc/krpc-core/src/wasmJsMain/kotlin/kotlinx/rpc/krpc/internal/ExceptionUtils.wasm.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2023-2024 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
+ * Copyright 2023-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/
@file:Suppress("detekt.MatchingDeclarationName")
@@ -24,7 +24,7 @@ internal actual class DeserializedException actual constructor(
internal actual fun Throwable.stackElements(): List = emptyList()
-@InternalRpcApi
-public actual fun SerializedException.deserialize(): Throwable {
- return DeserializedException(toStringMessage, message, stacktrace, cause, className)
+internal actual fun SerializedException.deserializeUnsafe(): Throwable {
+ return nonJvmManualCancellationExceptionDeserialize()
+ ?: DeserializedException(toStringMessage, message, stacktrace, cause, className)
}
diff --git a/krpc/krpc-core/src/wasmJsTest/kotlin/kotlinx/rpc/krpc/KrpcReceiveHandlerTest.wasmJs.kt b/krpc/krpc-core/src/wasmJsTest/kotlin/kotlinx/rpc/krpc/KrpcReceiveHandlerTest.wasmJs.kt
new file mode 100644
index 000000000..8b39cd68f
--- /dev/null
+++ b/krpc/krpc-core/src/wasmJsTest/kotlin/kotlinx/rpc/krpc/KrpcReceiveHandlerTest.wasmJs.kt
@@ -0,0 +1,8 @@
+/*
+ * 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.krpc
+
+internal actual val stressIterations: Int = 3000
+internal actual val stressBufferSize: Int = 1500
diff --git a/krpc/krpc-ktor/krpc-ktor-core/build.gradle.kts b/krpc/krpc-ktor/krpc-ktor-core/build.gradle.kts
index 6e27be3eb..a05708828 100644
--- a/krpc/krpc-ktor/krpc-ktor-core/build.gradle.kts
+++ b/krpc/krpc-ktor/krpc-ktor-core/build.gradle.kts
@@ -27,6 +27,7 @@ kotlin {
implementation(projects.krpc.krpcKtor.krpcKtorServer)
implementation(projects.krpc.krpcKtor.krpcKtorClient)
implementation(projects.krpc.krpcLogging)
+ implementation(projects.tests.testUtils)
implementation(libs.kotlin.test)
implementation(libs.ktor.server.netty)
diff --git a/krpc/krpc-ktor/krpc-ktor-core/src/jvmTest/kotlin/kotlinx/rpc/krpc/ktor/KtorTransportTest.kt b/krpc/krpc-ktor/krpc-ktor-core/src/jvmTest/kotlin/kotlinx/rpc/krpc/ktor/KtorTransportTest.kt
index 9eb506b42..680c6cfc5 100644
--- a/krpc/krpc-ktor/krpc-ktor-core/src/jvmTest/kotlin/kotlinx/rpc/krpc/ktor/KtorTransportTest.kt
+++ b/krpc/krpc-ktor/krpc-ktor-core/src/jvmTest/kotlin/kotlinx/rpc/krpc/ktor/KtorTransportTest.kt
@@ -18,21 +18,20 @@ import io.ktor.server.routing.*
import io.ktor.server.testing.*
import kotlinx.coroutines.*
import kotlinx.coroutines.debug.DebugProbes
-import kotlinx.coroutines.test.runTest
import kotlinx.rpc.annotations.Rpc
import kotlinx.rpc.krpc.client.KrpcClient
-import kotlinx.rpc.krpc.internal.logging.RpcInternalDumpLogger
+import kotlinx.rpc.krpc.internal.logging.RpcInternalCommonLogger
import kotlinx.rpc.krpc.internal.logging.RpcInternalDumpLoggerContainer
+import kotlinx.rpc.krpc.internal.logging.dumpLogger
import kotlinx.rpc.krpc.ktor.client.installKrpc
import kotlinx.rpc.krpc.ktor.client.rpc
import kotlinx.rpc.krpc.ktor.client.rpcConfig
import kotlinx.rpc.krpc.ktor.server.Krpc
import kotlinx.rpc.krpc.ktor.server.rpc
import kotlinx.rpc.krpc.serialization.json.json
+import kotlinx.rpc.test.runTestWithCoroutinesProbes
import kotlinx.rpc.withService
import org.junit.Assert.assertEquals
-import org.junit.platform.commons.logging.Logger
-import org.junit.platform.commons.logging.LoggerFactory
import java.net.ServerSocket
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
@@ -85,8 +84,6 @@ class KtorTransportTest {
ignoreUnknownKeys = true
}
}
-
- waitForServices = true
}
registerService { NewServiceImpl(call) }
@@ -138,7 +135,7 @@ class KtorTransportTest {
@OptIn(DelicateCoroutinesApi::class, ExperimentalCoroutinesApi::class)
@Test
@Ignore("Wait for Ktor fix (https://github.com/ktorio/ktor/pull/4927) or apply workaround if rejected")
- fun testEndpointsTerminateWhenWsDoes() = runTest(timeout = 15.seconds) {
+ fun testEndpointsTerminateWhenWsDoes() = runTestWithCoroutinesProbes(timeout = 15.seconds) {
DebugProbes.install()
val logger = setupLogger()
@@ -247,17 +244,10 @@ class KtorTransportTest {
return port
}
- private fun setupLogger(): Logger {
- val logger = LoggerFactory.getLogger(KtorTransportTest::class.java)
-
- RpcInternalDumpLoggerContainer.set(object : RpcInternalDumpLogger {
+ private fun setupLogger(): RpcInternalCommonLogger {
+ val logger = RpcInternalCommonLogger.logger(KtorTransportTest::class)
- override val isEnabled: Boolean = true
-
- override fun dump(vararg tags: String, message: () -> String) {
- logger.info { "[${tags.joinToString()}] ${message()}" }
- }
- })
+ RpcInternalDumpLoggerContainer.set(logger.dumpLogger())
return logger
}
diff --git a/krpc/krpc-logging/build.gradle.kts b/krpc/krpc-logging/build.gradle.kts
index 11ec6d04e..be0f4ee9c 100644
--- a/krpc/krpc-logging/build.gradle.kts
+++ b/krpc/krpc-logging/build.gradle.kts
@@ -1,5 +1,5 @@
/*
- * Copyright 2023-2024 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
+ * Copyright 2023-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/
plugins {
@@ -15,5 +15,11 @@ kotlin {
implementation(projects.utils)
}
}
+
+ jvmMain {
+ dependencies {
+ implementation(libs.slf4j.api)
+ }
+ }
}
}
diff --git a/krpc/krpc-logging/src/commonMain/kotlin/kotlinx/rpc/krpc/internal/logging/RpcInternalDumpLogger.kt b/krpc/krpc-logging/src/commonMain/kotlin/kotlinx/rpc/krpc/internal/logging/RpcInternalDumpLogger.kt
index 1405167fe..9dcec545b 100644
--- a/krpc/krpc-logging/src/commonMain/kotlin/kotlinx/rpc/krpc/internal/logging/RpcInternalDumpLogger.kt
+++ b/krpc/krpc-logging/src/commonMain/kotlin/kotlinx/rpc/krpc/internal/logging/RpcInternalDumpLogger.kt
@@ -13,6 +13,17 @@ public interface RpcInternalDumpLogger {
public fun dump(vararg tags: String, message: () -> String)
}
+@InternalRpcApi
+public fun RpcInternalCommonLogger.dumpLogger(): RpcInternalDumpLogger {
+ return object : RpcInternalDumpLogger {
+ override val isEnabled: Boolean = true
+
+ override fun dump(vararg tags: String, message: () -> String) {
+ this@dumpLogger.info { "${tags.joinToString(" ") { "[$it]" }} ${message()}" }
+ }
+ }
+}
+
@InternalRpcApi
public object RpcInternalDumpLoggerContainer {
private var logger: RpcInternalDumpLogger? = null
diff --git a/krpc/krpc-serialization/krpc-serialization-core/api/krpc-serialization-core.api b/krpc/krpc-serialization/krpc-serialization-core/api/krpc-serialization-core.api
index 78a34cca3..0382934af 100644
--- a/krpc/krpc-serialization/krpc-serialization-core/api/krpc-serialization-core.api
+++ b/krpc/krpc-serialization/krpc-serialization-core/api/krpc-serialization-core.api
@@ -1,7 +1,6 @@
public abstract interface class kotlinx/rpc/krpc/serialization/KrpcSerialFormat {
public abstract fun applySerializersModule (Ljava/lang/Object;Lkotlinx/serialization/modules/SerializersModule;)V
public abstract fun withBuilder (Lkotlinx/serialization/SerialFormat;Lkotlin/jvm/functions/Function1;)Lkotlinx/serialization/SerialFormat;
- public static synthetic fun withBuilder$default (Lkotlinx/rpc/krpc/serialization/KrpcSerialFormat;Lkotlinx/serialization/SerialFormat;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lkotlinx/serialization/SerialFormat;
}
public final class kotlinx/rpc/krpc/serialization/KrpcSerialFormat$DefaultImpls {
diff --git a/krpc/krpc-server/api/krpc-server.api b/krpc/krpc-server/api/krpc-server.api
index 7d1f9c37c..d5d349f34 100644
--- a/krpc/krpc-server/api/krpc-server.api
+++ b/krpc/krpc-server/api/krpc-server.api
@@ -3,7 +3,7 @@ public abstract class kotlinx/rpc/krpc/server/KrpcServer : kotlinx/rpc/RpcServer
public final fun awaitCompletion (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public final fun close (Ljava/lang/String;)V
public static synthetic fun close$default (Lkotlinx/rpc/krpc/server/KrpcServer;Ljava/lang/String;ILjava/lang/Object;)V
- public fun deregisterService (Lkotlin/reflect/KClass;)V
+ public final fun deregisterService (Lkotlin/reflect/KClass;)V
public final fun registerService (Lkotlin/reflect/KClass;Lkotlin/jvm/functions/Function0;)V
}
diff --git a/krpc/krpc-server/api/krpc-server.klib.api b/krpc/krpc-server/api/krpc-server.klib.api
index e7f08f0ed..05eca67ef 100644
--- a/krpc/krpc-server/api/krpc-server.klib.api
+++ b/krpc/krpc-server/api/krpc-server.klib.api
@@ -9,8 +9,8 @@
abstract class kotlinx.rpc.krpc.server/KrpcServer : kotlinx.rpc.krpc.internal/KrpcEndpoint, kotlinx.rpc/RpcServer { // kotlinx.rpc.krpc.server/KrpcServer|null[0]
constructor (kotlinx.rpc.krpc/KrpcConfig.Server, kotlinx.rpc.krpc/KrpcTransport) // kotlinx.rpc.krpc.server/KrpcServer.|(kotlinx.rpc.krpc.KrpcConfig.Server;kotlinx.rpc.krpc.KrpcTransport){}[0]
+ final fun <#A1: kotlin/Any> deregisterService(kotlin.reflect/KClass<#A1>) // kotlinx.rpc.krpc.server/KrpcServer.deregisterService|deregisterService(kotlin.reflect.KClass<0:0>){0§}[0]
final fun <#A1: kotlin/Any> registerService(kotlin.reflect/KClass<#A1>, kotlin/Function0<#A1>) // kotlinx.rpc.krpc.server/KrpcServer.registerService|registerService(kotlin.reflect.KClass<0:0>;kotlin.Function0<0:0>){0§}[0]
final fun close(kotlin/String? = ...) // kotlinx.rpc.krpc.server/KrpcServer.close|close(kotlin.String?){}[0]
final suspend fun awaitCompletion() // kotlinx.rpc.krpc.server/KrpcServer.awaitCompletion|awaitCompletion(){}[0]
- open fun <#A1: kotlin/Any> deregisterService(kotlin.reflect/KClass<#A1>) // kotlinx.rpc.krpc.server/KrpcServer.deregisterService|deregisterService(kotlin.reflect.KClass<0:0>){0§}[0]
}
diff --git a/krpc/krpc-server/src/commonMain/kotlin/kotlinx/rpc/krpc/server/KrpcServer.kt b/krpc/krpc-server/src/commonMain/kotlin/kotlinx/rpc/krpc/server/KrpcServer.kt
index 1e45590a6..24545fac6 100644
--- a/krpc/krpc-server/src/commonMain/kotlin/kotlinx/rpc/krpc/server/KrpcServer.kt
+++ b/krpc/krpc-server/src/commonMain/kotlin/kotlinx/rpc/krpc/server/KrpcServer.kt
@@ -70,7 +70,7 @@ public abstract class KrpcServer(
KrpcServerConnector(
serialFormat = config.serialFormatInitializer.build(),
transport = transport,
- waitForServices = config.waitForServices,
+ config = config.connector,
)
}
@@ -133,7 +133,7 @@ public abstract class KrpcServer(
}
}
- override fun <@Rpc Service : Any> deregisterService(serviceKClass: KClass) {
+ final override fun <@Rpc Service : Any> deregisterService(serviceKClass: KClass) {
connector.unsubscribeFromServiceMessages(serviceDescriptorOf(serviceKClass).fqName)
rpcServices.remove(serviceDescriptorOf(serviceKClass).fqName)
}
@@ -175,7 +175,7 @@ public abstract class KrpcServer(
else -> {
logger.warn {
"Unsupported ${KrpcPluginKey.CANCELLATION_TYPE} $type for server, " +
- "only 'endpoint' type may be sent by a server"
+ "only 'endpoint' type may be sent by a client"
}
}
}
diff --git a/krpc/krpc-server/src/commonMain/kotlin/kotlinx/rpc/krpc/server/internal/KrpcServerConnector.kt b/krpc/krpc-server/src/commonMain/kotlin/kotlinx/rpc/krpc/server/internal/KrpcServerConnector.kt
index 886441247..3c669be4f 100644
--- a/krpc/krpc-server/src/commonMain/kotlin/kotlinx/rpc/krpc/server/internal/KrpcServerConnector.kt
+++ b/krpc/krpc-server/src/commonMain/kotlin/kotlinx/rpc/krpc/server/internal/KrpcServerConnector.kt
@@ -1,61 +1,51 @@
/*
- * Copyright 2023-2024 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
+ * 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.krpc.server.internal
+import kotlinx.rpc.krpc.KrpcConfig
import kotlinx.rpc.krpc.KrpcTransport
import kotlinx.rpc.krpc.internal.*
import kotlinx.serialization.SerialFormat
-internal sealed interface MessageKey {
- data class Service(val serviceTypeString: String): MessageKey
-
- data object Protocol: MessageKey
-
- data object Generic: MessageKey
-}
-
internal class KrpcServerConnector private constructor(
- private val connector: KrpcConnector,
-): KrpcMessageSender by connector {
+ private val connector: KrpcConnector,
+) : KrpcMessageSender by connector {
constructor(
serialFormat: SerialFormat,
transport: KrpcTransport,
- waitForServices: Boolean = false,
+ config: KrpcConfig.Connector,
) : this(
- KrpcConnector(serialFormat, transport, waitForServices, isServer = true) {
- when (this) {
- is KrpcCallMessage -> MessageKey.Service(serviceType)
- is KrpcProtocolMessage -> MessageKey.Protocol
- is KrpcGenericMessage -> MessageKey.Generic
- }
- }
+ KrpcConnector(serialFormat, transport, config, isServer = true)
)
- fun unsubscribeFromServiceMessages(serviceTypeString: String, callback: () -> Unit = {}) {
- connector.unsubscribeFromMessages(MessageKey.Service(serviceTypeString), callback)
+ fun unsubscribeFromServiceMessages(serviceTypeString: String, callback: suspend () -> Unit = {}) {
+ connector.unsubscribeFromMessagesAsync(HandlerKey.Service(serviceTypeString), callback)
+ }
+
+ suspend fun unsubscribeFromCallMessages(serviceTypeString: String, callId: String) {
+ connector.unsubscribeFromMessages(HandlerKey.ServiceCall(serviceTypeString, callId))
}
suspend fun subscribeToServiceMessages(
serviceTypeString: String,
subscription: suspend (KrpcCallMessage) -> Unit,
) {
- connector.subscribeToMessages(MessageKey.Service(serviceTypeString)) {
- subscription(it as KrpcCallMessage)
+ connector.subscribeToMessages(HandlerKey.Service(serviceTypeString)) {
+ subscription(it)
}
}
suspend fun subscribeToProtocolMessages(subscription: suspend (KrpcProtocolMessage) -> Unit) {
- connector.subscribeToMessages(MessageKey.Protocol) {
- subscription(it as KrpcProtocolMessage)
+ connector.subscribeToMessages(HandlerKey.Protocol) {
+ subscription(it)
}
}
- @Suppress("unused")
suspend fun subscribeToGenericMessages(subscription: suspend (KrpcGenericMessage) -> Unit) {
- connector.subscribeToMessages(MessageKey.Generic) {
- subscription(it as KrpcGenericMessage)
+ connector.subscribeToMessages(HandlerKey.Generic) {
+ subscription(it)
}
}
}
diff --git a/krpc/krpc-server/src/commonMain/kotlin/kotlinx/rpc/krpc/server/internal/KrpcServerService.kt b/krpc/krpc-server/src/commonMain/kotlin/kotlinx/rpc/krpc/server/internal/KrpcServerService.kt
index 9e0afcf5d..2afac9ebc 100644
--- a/krpc/krpc-server/src/commonMain/kotlin/kotlinx/rpc/krpc/server/internal/KrpcServerService.kt
+++ b/krpc/krpc-server/src/commonMain/kotlin/kotlinx/rpc/krpc/server/internal/KrpcServerService.kt
@@ -41,25 +41,26 @@ internal class KrpcServerService<@Rpc T : Any>(
val exception = result.exceptionOrNull()
?: error("Expected exception value")
- cancelRequest(
+ closeReceiving(
callId = message.callId,
message = "Cancelled after failed to process message: $message, error message: ${exception.message}",
cause = exception,
)
if (exception is CancellationException) {
- return
+ currentCoroutineContext().ensureActive()
}
- val error = serializeException(exception)
+ val serialized = serializeException(exception)
val errorMessage = KrpcCallMessage.CallException(
callId = message.callId,
serviceType = message.serviceType,
- cause = error,
+ cause = serialized,
connectionId = message.connectionId,
)
connector.sendMessage(errorMessage)
+ unsubscribeFromCallMessages(message.callId)
}
}
@@ -72,7 +73,7 @@ internal class KrpcServerService<@Rpc T : Any>(
}
is KrpcCallMessage.CallException -> {
- cancelRequest(
+ closeReceiving(
callId = message.callId,
message = "Cancelled after KrpcCallMessage.CallException received",
cause = message.cause.deserialize(),
@@ -181,24 +182,32 @@ internal class KrpcServerService<@Rpc T : Any>(
sendMessages(serialFormat, returnSerializer, value, callData)
}
} catch (cause: CancellationException) {
- throw cause
- } catch (@Suppress("detekt.TooGenericExceptionCaught") cause: Throwable) {
+ currentCoroutineContext().ensureActive()
+
+ val wrapped = ManualCancellationException(cause)
+
+ failure = wrapped
+ } catch (cause: Throwable) {
failure = cause
+ } finally {
+ if (failure != null) {
+ val serializedCause = serializeException(failure)
+ val exceptionMessage = KrpcCallMessage.CallException(
+ callId = callId,
+ serviceType = descriptor.fqName,
+ cause = serializedCause,
+ connectionId = callData.connectionId,
+ serviceId = callData.serviceId,
+ )
- val serializedCause = serializeException(cause)
- KrpcCallMessage.CallException(
- callId = callId,
- serviceType = descriptor.fqName,
- cause = serializedCause,
- connectionId = callData.connectionId,
- serviceId = callData.serviceId,
- ).also { connector.sendMessage(it) }
- }
+ connector.sendMessage(exceptionMessage)
+
+ closeReceiving(callId, "Server request failed", failure, fromJob = true)
+ } else {
+ closeReceiving(callId, fromJob = true)
+ }
- if (failure != null) {
- cancelRequest(callId, "Server request failed", failure, fromJob = true)
- } else {
- cancelRequest(callId, fromJob = true)
+ unsubscribeFromCallMessages(callId)
}
}
@@ -207,6 +216,10 @@ internal class KrpcServerService<@Rpc T : Any>(
requestJob.start()
}
+ suspend fun unsubscribeFromCallMessages(callId: String) {
+ connector.unsubscribeFromCallMessages(descriptor.fqName, callId)
+ }
+
private suspend fun sendMessages(
serialFormat: SerialFormat,
returnSerializer: KSerializer,
@@ -296,7 +309,7 @@ internal class KrpcServerService<@Rpc T : Any>(
)
} catch (cause: CancellationException) {
throw cause
- } catch (@Suppress("detekt.TooGenericExceptionCaught") cause: Throwable) {
+ } catch (cause: Throwable) {
val serializedCause = serializeException(cause)
connector.sendMessage(
KrpcCallMessage.StreamCancel(
@@ -311,7 +324,7 @@ internal class KrpcServerService<@Rpc T : Any>(
}
}
- suspend fun cancelRequest(
+ suspend fun closeReceiving(
callId: String,
message: String? = null,
cause: Throwable? = null,
@@ -325,7 +338,7 @@ internal class KrpcServerService<@Rpc T : Any>(
message: String? = null,
cause: Throwable? = null,
) {
- cancelRequest(callId, message, cause, fromJob = false)
+ closeReceiving(callId, message, cause, fromJob = false)
if (!supportedPlugins.contains(KrpcPlugin.NO_ACK_CANCELLATION)) {
connector.sendMessage(
@@ -339,6 +352,8 @@ internal class KrpcServerService<@Rpc T : Any>(
)
)
}
+
+ unsubscribeFromCallMessages(callId)
}
private val serverStreamContext: ServerStreamContext = ServerStreamContext()
diff --git a/krpc/krpc-server/src/commonMain/kotlin/kotlinx/rpc/krpc/server/internal/ServerStreamContext.kt b/krpc/krpc-server/src/commonMain/kotlin/kotlinx/rpc/krpc/server/internal/ServerStreamContext.kt
index 0311dd4fa..7d99e1ff9 100644
--- a/krpc/krpc-server/src/commonMain/kotlin/kotlinx/rpc/krpc/server/internal/ServerStreamContext.kt
+++ b/krpc/krpc-server/src/commonMain/kotlin/kotlinx/rpc/krpc/server/internal/ServerStreamContext.kt
@@ -55,7 +55,7 @@ internal class ServerStreamContext {
fun prepareClientStream(streamId: String, elementKind: KSerializer): Flow {
val callId = currentCallId.get() ?: error("No call id")
- val channel = Channel(Channel.UNLIMITED)
+ val channel = Channel()
@Suppress("UNCHECKED_CAST")
val map = streams.computeIfAbsent(callId) { RpcInternalConcurrentHashMap() }
diff --git a/krpc/krpc-test/build.gradle.kts b/krpc/krpc-test/build.gradle.kts
index 288bf56dd..758e6b45f 100644
--- a/krpc/krpc-test/build.gradle.kts
+++ b/krpc/krpc-test/build.gradle.kts
@@ -6,7 +6,6 @@ import org.jetbrains.kotlin.gradle.dsl.ExplicitApiMode
import org.jetbrains.kotlin.gradle.dsl.KotlinVersion
import org.jetbrains.kotlin.gradle.targets.js.testing.KotlinJsTest
import org.jetbrains.kotlin.gradle.targets.jvm.tasks.KotlinJvmTest
-import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import java.nio.file.Files
plugins {
@@ -38,6 +37,8 @@ kotlin {
api(projects.krpc.krpcClient)
api(projects.krpc.krpcLogging)
+ implementation(projects.tests.testUtils)
+
implementation(projects.krpc.krpcSerialization.krpcSerializationJson)
implementation(libs.serialization.core)
@@ -71,6 +72,7 @@ kotlin {
implementation(libs.slf4j.api)
implementation(libs.logback.classic)
implementation(libs.coroutines.debug)
+ implementation(libs.lincheck)
}
}
}
@@ -79,7 +81,15 @@ kotlin {
}
tasks.withType {
+ // lincheck agent
+ jvmArgs("-XX:+EnableDynamicAgentLoading")
environment("LIBRARY_VERSION", libs.versions.kotlinx.rpc.get())
+
+ if (project.hasProperty("stressTests") && project.property("stressTests") == "true") {
+ include("kotlinx/rpc/krpc/test/stress/**")
+ } else {
+ exclude("kotlinx/rpc/krpc/test/stress/**")
+ }
}
val resourcesPath = projectDir.resolve("src/jvmTest/resources")
diff --git a/krpc/krpc-test/src/commonMain/kotlin/kotlinx/rpc/krpc/test/KrpcTestService.kt b/krpc/krpc-test/src/commonMain/kotlin/kotlinx/rpc/krpc/test/KrpcTestService.kt
index b3e803c98..1c46e57c9 100644
--- a/krpc/krpc-test/src/commonMain/kotlin/kotlinx/rpc/krpc/test/KrpcTestService.kt
+++ b/krpc/krpc-test/src/commonMain/kotlin/kotlinx/rpc/krpc/test/KrpcTestService.kt
@@ -48,6 +48,7 @@ interface KrpcTestService {
fun nonSuspendBidirectional(flow: Flow): Flow
fun nonSuspendBidirectionalPayload(payloadWithStream: PayloadWithStream): Flow
+ fun slowConsumer(): Flow
suspend fun empty()
suspend fun returnType(): String
suspend fun simpleWithParams(name: String): String
diff --git a/krpc/krpc-test/src/commonMain/kotlin/kotlinx/rpc/krpc/test/KrpcTestServiceBackend.kt b/krpc/krpc-test/src/commonMain/kotlin/kotlinx/rpc/krpc/test/KrpcTestServiceBackend.kt
index 5042bb308..bd1b386bc 100644
--- a/krpc/krpc-test/src/commonMain/kotlin/kotlinx/rpc/krpc/test/KrpcTestServiceBackend.kt
+++ b/krpc/krpc-test/src/commonMain/kotlin/kotlinx/rpc/krpc/test/KrpcTestServiceBackend.kt
@@ -6,7 +6,7 @@ package kotlinx.rpc.krpc.test
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
-import kotlinx.coroutines.test.TestScope
+import kotlinx.rpc.test.runThreadIfPossible
import kotlinx.serialization.Serializable
import kotlin.coroutines.resumeWithException
import kotlin.test.assertContentEquals
@@ -42,6 +42,15 @@ class KrpcTestServiceBackend : KrpcTestService {
return payloadWithStream.stream.map { it.length }
}
+ override fun slowConsumer(): Flow {
+ return flow {
+ repeat(10) {
+ delay(100)
+ emit(it)
+ }
+ }
+ }
+
@Suppress("detekt.EmptyFunctionBlock")
override suspend fun empty() {}
@@ -98,7 +107,9 @@ class KrpcTestServiceBackend : KrpcTestService {
return arg1
}
- override suspend fun returnTestClassThatThrowsWhileDeserialization(value: Int): TestClassThatThrowsWhileDeserialization {
+ override suspend fun returnTestClassThatThrowsWhileDeserialization(
+ value: Int,
+ ): TestClassThatThrowsWhileDeserialization {
return TestClassThatThrowsWhileDeserialization(value)
}
@@ -147,12 +158,19 @@ class KrpcTestServiceBackend : KrpcTestService {
return arg1.count()
}
+ @OptIn(DelicateCoroutinesApi::class)
+ @Suppress("detekt.GlobalCoroutineUsage")
override suspend fun incomingStreamSyncCollectMultiple(
arg1: Flow,
arg2: Flow,
arg3: Flow,
): Int {
- return arg1.count() + arg2.count() + arg3.count()
+ // buffer of size 1 may cause lock here without multiple coroutines
+ return listOf(
+ GlobalScope.async { arg1.count() },
+ GlobalScope.async { arg2.count() },
+ GlobalScope.async { arg3.count() },
+ ).awaitAll().sum()
}
override fun outgoingStream(): Flow {
@@ -250,7 +268,3 @@ class KrpcTestServiceBackend : KrpcTestService {
}
}
}
-
-internal expect fun runThreadIfPossible(runner: () -> Unit)
-
-internal expect fun TestScope.debugCoroutines()
diff --git a/krpc/krpc-test/src/commonMain/kotlin/kotlinx/rpc/krpc/test/KrpcTransportTestBase.kt b/krpc/krpc-test/src/commonMain/kotlin/kotlinx/rpc/krpc/test/KrpcTransportTestBase.kt
index d343c376f..581439b04 100644
--- a/krpc/krpc-test/src/commonMain/kotlin/kotlinx/rpc/krpc/test/KrpcTransportTestBase.kt
+++ b/krpc/krpc-test/src/commonMain/kotlin/kotlinx/rpc/krpc/test/KrpcTransportTestBase.kt
@@ -11,13 +11,15 @@ import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.sync.Semaphore
-import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.TestResult
+import kotlinx.coroutines.test.TestScope
import kotlinx.rpc.krpc.KrpcTransport
import kotlinx.rpc.krpc.rpcClientConfig
import kotlinx.rpc.krpc.rpcServerConfig
import kotlinx.rpc.krpc.serialization.KrpcSerialFormatConfiguration
import kotlinx.rpc.krpc.server.KrpcServer
import kotlinx.rpc.registerService
+import kotlinx.rpc.test.runTestWithCoroutinesProbes
import kotlinx.rpc.withService
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerializationException
@@ -29,6 +31,7 @@ import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.modules.SerializersModule
import kotlin.coroutines.cancellation.CancellationException
import kotlin.test.*
+import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
internal object LocalDateSerializer : KSerializer {
@@ -283,6 +286,23 @@ abstract class KrpcTransportTestBase {
assertEquals(9, result)
}
+ @Test
+ fun slowConsumer() = runTest {
+ val foreverAwait = CompletableDeferred()
+ val collectFirst = CompletableDeferred()
+ val slow = launch {
+ client.slowConsumer().collect {
+ collectFirst.complete(Unit)
+ foreverAwait.await()
+ }
+ }
+
+ collectFirst.await()
+ val fast = client.simpleWithParams("test")
+ assertEquals(fast, "tset")
+ slow.cancelAndJoin()
+ }
+
@Test
fun outgoingStream() = runTest {
val result = client.outgoingStream()
@@ -311,9 +331,16 @@ abstract class KrpcTransportTestBase {
}
@Test
- fun RPC_should_be_able_to_receive_100_000_ints_in_reasonable_time() = runTest(timeout = JS_EXTENDED_TIMEOUT) {
+ fun RPC_should_be_able_to_receive_100_000_ints_in_reasonable_time() = runTest(timeout = EXTENDED_TIMEOUT) {
val n = 100_000
- assertEquals(client.getNInts(n).last(), n)
+ var counter = 0
+ val last = client.getNInts(n).onEach {
+ counter++
+ if (counter % 1000 == 0) {
+ println("Iteration: $counter")
+ }
+ }.last()
+ assertEquals(n, last)
}
@Test
@@ -330,7 +357,7 @@ abstract class KrpcTransportTestBase {
}
@Test
- @Suppress("detekt.TooGenericExceptionCaught", "detekt.ThrowsCount")
+ @Suppress("detekt.ThrowsCount")
fun testExceptionSerializationAndPropagating() = runTest {
try {
client.throwsIllegalArgument("me")
@@ -472,8 +499,12 @@ abstract class KrpcTransportTestBase {
}
}
}
+
+ private fun runTest(timeout: Duration = 10.seconds, testBody: suspend TestScope.() -> Unit): TestResult {
+ return runTestWithCoroutinesProbes(timeout = timeout, body = testBody)
+ }
}
-private val JS_EXTENDED_TIMEOUT = if (isJs) 300.seconds else 60.seconds
+private val EXTENDED_TIMEOUT = if (isJs) 500.seconds else 200.seconds
internal expect val isJs: Boolean
diff --git a/krpc/krpc-test/src/commonTest/kotlin/kotlinx/rpc/krpc/test/BackPressureTest.kt b/krpc/krpc-test/src/commonTest/kotlin/kotlinx/rpc/krpc/test/BackPressureTest.kt
new file mode 100644
index 000000000..4297462c6
--- /dev/null
+++ b/krpc/krpc-test/src/commonTest/kotlin/kotlinx/rpc/krpc/test/BackPressureTest.kt
@@ -0,0 +1,200 @@
+/*
+ * 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.krpc.test
+
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.async
+import kotlinx.coroutines.cancelAndJoin
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.toList
+import kotlinx.coroutines.job
+import kotlinx.coroutines.test.TestScope
+import kotlinx.rpc.annotations.Rpc
+import kotlinx.rpc.krpc.internal.logging.RpcInternalDumpLoggerContainer
+import kotlinx.rpc.krpc.rpcClientConfig
+import kotlinx.rpc.krpc.rpcServerConfig
+import kotlinx.rpc.krpc.serialization.json.json
+import kotlinx.rpc.registerService
+import kotlinx.rpc.test.WaitCounter
+import kotlinx.rpc.test.runTestWithCoroutinesProbes
+import kotlinx.rpc.withService
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
+
+@Rpc
+interface BackPressure {
+ suspend fun plain()
+
+ fun serverStream(num: Int): Flow
+
+ suspend fun clientStream(flow: Flow)
+}
+
+class BackPressureImpl : BackPressure {
+ val plainCounter = WaitCounter()
+ val serverStreamCounter = WaitCounter()
+ val clientStreamCounter = WaitCounter()
+ val entered = CompletableDeferred()
+ val fence = CompletableDeferred()
+
+ override suspend fun plain() {
+ plainCounter.increment()
+ }
+
+ override fun serverStream(num: Int): Flow {
+ return flow {
+ repeat(num) {
+ serverStreamCounter.increment()
+ emit(it)
+ }
+ }
+ }
+
+ val consumed = mutableListOf()
+ override suspend fun clientStream(flow: Flow) {
+ flow.collect {
+ if (it == 0) {
+ entered.complete(Unit)
+ fence.await()
+ }
+ consumed.add(it)
+ }
+ }
+}
+
+class BackPressureTest : BackPressureTestBase() {
+ @Test
+ fun buffer_size_1_server() = runServerTest(perCallBufferSize = 1)
+
+ @Test
+ fun buffer_size_10_server() = runServerTest(perCallBufferSize = 10)
+
+ @Test
+ fun buffer_size_1_client() = runClientTest(perCallBufferSize = 1)
+
+ @Test
+ fun buffer_size_10_client() = runClientTest(perCallBufferSize = 10)
+}
+
+// `+2` explanation:
+// 1. the first element is sent and processed by the client
+// 2. the second element is sent and is suspended on the client
+// because the processing of the first element is not finished yet
+// 3. the third element is n from the flow and suspended on the server side
+abstract class BackPressureTestBase {
+ protected fun runServerTest(
+ perCallBufferSize: Int,
+ timeout: Duration = 30.seconds,
+ ) = runTest(perCallBufferSize, timeout) { service, impl ->
+ var counter = 0
+ val flowList = async {
+ service.serverStream(1000).map {
+ if (it == 0) {
+ impl.entered.complete(Unit)
+ impl.fence.await()
+ }
+ counter++
+ if (counter % 10 == 0) {
+ println("Iteration: $counter")
+ }
+ }.toList()
+ }
+
+ impl.entered.await()
+ impl.serverStreamCounter.await(perCallBufferSize + 2)
+
+ repeat(1000) {
+ service.plain()
+ }
+
+ impl.plainCounter.await(1000)
+
+ assertEquals(perCallBufferSize + 2, impl.serverStreamCounter.value)
+ impl.fence.complete(Unit)
+ impl.serverStreamCounter.await(1000)
+ assertEquals(1000, flowList.await().size)
+ }
+
+ protected fun runClientTest(
+ perCallBufferSize: Int,
+ timeout: Duration = 30.seconds,
+ ) = runTest(perCallBufferSize, timeout) { service, impl ->
+ var counter = 0
+ val flowList = async {
+ service.clientStream(flow {
+ repeat(1000) {
+ impl.clientStreamCounter.increment()
+ emit(it)
+ counter++
+ if (counter % 10 == 0) {
+ println("Iteration: $counter")
+ }
+ }
+ })
+ }
+
+ impl.entered.await()
+ impl.clientStreamCounter.await(perCallBufferSize + 2)
+
+ repeat(1000) {
+ service.plain()
+ }
+
+ impl.plainCounter.await(1000)
+
+ assertEquals(0, impl.consumed.size)
+ assertEquals(perCallBufferSize + 2, impl.clientStreamCounter.value)
+ impl.fence.complete(Unit)
+ impl.clientStreamCounter.await(1000)
+ flowList.await()
+ assertEquals(1000, impl.consumed.size)
+ }
+
+ protected fun runTest(
+ perCallBufferSize: Int,
+ timeout: Duration = 10.seconds,
+ body: suspend TestScope.(BackPressure, BackPressureImpl) -> Unit,
+ ) = runTestWithCoroutinesProbes(timeout = timeout) {
+ val transport = LocalTransport(coroutineContext, recordTimestamps = false)
+ val clientConfig = rpcClientConfig {
+ serialization {
+ json()
+ }
+
+ connector {
+ this.perCallBufferSize = perCallBufferSize
+ }
+ }
+ val client = KrpcTestClient(clientConfig, transport.client)
+ val serverConfig = rpcServerConfig {
+ serialization {
+ json()
+ }
+
+ connector {
+ this.perCallBufferSize = perCallBufferSize
+ }
+ }
+ val server = KrpcTestServer(serverConfig, transport.server)
+ val impl = BackPressureImpl()
+ server.registerService { impl }
+ val service = client.withService()
+
+ try {
+ body(service, impl)
+ } finally {
+ RpcInternalDumpLoggerContainer.set(null)
+ client.close()
+ server.close()
+ client.awaitCompletion()
+ server.awaitCompletion()
+ transport.coroutineContext.job.cancelAndJoin()
+ }
+ }
+}
diff --git a/krpc/krpc-test/src/commonTest/kotlin/kotlinx/rpc/krpc/test/CoroutineContextPropagationTest.kt b/krpc/krpc-test/src/commonTest/kotlin/kotlinx/rpc/krpc/test/CoroutineContextPropagationTest.kt
index a0a3704a5..aade86b3a 100644
--- a/krpc/krpc-test/src/commonTest/kotlin/kotlinx/rpc/krpc/test/CoroutineContextPropagationTest.kt
+++ b/krpc/krpc-test/src/commonTest/kotlin/kotlinx/rpc/krpc/test/CoroutineContextPropagationTest.kt
@@ -4,16 +4,19 @@
package kotlinx.rpc.krpc.test
+import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.currentCoroutineContext
-import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.job
import kotlinx.coroutines.withContext
import kotlinx.rpc.krpc.rpcClientConfig
import kotlinx.rpc.krpc.rpcServerConfig
import kotlinx.rpc.krpc.serialization.json.json
+import kotlinx.rpc.test.runTestWithCoroutinesProbes
import kotlinx.rpc.withService
import kotlin.coroutines.CoroutineContext
import kotlin.test.Test
import kotlin.test.assertEquals
+import kotlin.time.Duration.Companion.seconds
class CoroutineContextPropagationTest {
private val rpcServerConfig = rpcServerConfig {
@@ -36,7 +39,7 @@ class CoroutineContextPropagationTest {
}
@Test
- fun test() = runTest {
+ fun test() = runTestWithCoroutinesProbes(timeout = 60.seconds) {
var actualContext: CoroutineElement? = null
val transport = LocalTransport(CoroutineElement("transport"))
val server = KrpcTestServer(rpcServerConfig, transport.server)
@@ -51,9 +54,17 @@ class CoroutineContextPropagationTest {
}
}
}
- withContext(CoroutineElement("client")) {
- client.withService(Echo::class).echo("request")
+ try {
+ withContext(CoroutineElement("client")) {
+ client.withService(Echo::class).echo("request")
+ }
+ assertEquals(CoroutineElement("transport"), actualContext)
+ } finally {
+ server.close()
+ client.close()
+ server.awaitCompletion()
+ client.awaitCompletion()
+ transport.coroutineContext.job.cancelAndJoin()
}
- assertEquals(CoroutineElement("transport"), actualContext)
}
}
diff --git a/krpc/krpc-test/src/commonTest/kotlin/kotlinx/rpc/krpc/test/LocalTransport.kt b/krpc/krpc-test/src/commonTest/kotlin/kotlinx/rpc/krpc/test/LocalTransport.kt
index 5f9073d46..a91134667 100644
--- a/krpc/krpc-test/src/commonTest/kotlin/kotlinx/rpc/krpc/test/LocalTransport.kt
+++ b/krpc/krpc-test/src/commonTest/kotlin/kotlinx/rpc/krpc/test/LocalTransport.kt
@@ -18,6 +18,7 @@ import kotlin.time.ExperimentalTime
class LocalTransport(
parentContext: CoroutineContext? = null,
+ private val recordTimestamps: Boolean = true,
) : CoroutineScope {
override val coroutineContext = SupervisorJob(parentContext?.get(Job))
@@ -34,7 +35,9 @@ class LocalTransport(
@OptIn(ExperimentalTime::class)
override suspend fun send(message: KrpcTransportMessage) {
- lastMessageSentOnClient.getAndSet(Clock.System.now().toEpochMilliseconds())
+ if (recordTimestamps) {
+ lastMessageSentOnClient.getAndSet(Clock.System.now().toEpochMilliseconds())
+ }
serverIncoming.send(message)
}
@@ -50,7 +53,9 @@ class LocalTransport(
@OptIn(ExperimentalTime::class)
override suspend fun send(message: KrpcTransportMessage) {
- lastMessageSentOnServer.getAndSet(Clock.System.now().toEpochMilliseconds())
+ if (recordTimestamps) {
+ lastMessageSentOnServer.getAndSet(Clock.System.now().toEpochMilliseconds())
+ }
clientIncoming.send(message)
}
diff --git a/krpc/krpc-test/src/commonTest/kotlin/kotlinx/rpc/krpc/test/ProtocolTestBase.kt b/krpc/krpc-test/src/commonTest/kotlin/kotlinx/rpc/krpc/test/ProtocolTestBase.kt
index f714c20d7..bf50b6303 100644
--- a/krpc/krpc-test/src/commonTest/kotlin/kotlinx/rpc/krpc/test/ProtocolTestBase.kt
+++ b/krpc/krpc-test/src/commonTest/kotlin/kotlinx/rpc/krpc/test/ProtocolTestBase.kt
@@ -21,8 +21,10 @@ import kotlinx.rpc.krpc.rpcServerConfig
import kotlinx.rpc.krpc.serialization.json.json
import kotlinx.rpc.krpc.server.KrpcServer
import kotlinx.rpc.registerService
+import kotlinx.rpc.test.runTestWithCoroutinesProbes
import kotlinx.rpc.withService
import kotlinx.serialization.BinaryFormat
+import kotlin.time.Duration.Companion.seconds
abstract class ProtocolTestBase {
protected fun runTest(
@@ -38,10 +40,14 @@ abstract class ProtocolTestBase {
},
block: suspend TestBody.() -> Unit,
): TestResult {
- return kotlinx.coroutines.test.runTest {
- val finished = TestBody(clientConfig, serverConfig, this).apply { block() }
+ return runTestWithCoroutinesProbes(timeout = 60.seconds) {
+ val finished = TestBody(clientConfig, serverConfig, this)
- finished.transport.coroutineContext.job.cancelAndJoin()
+ try {
+ finished.block()
+ } finally {
+ finished.transport.coroutineContext.job.cancelAndJoin()
+ }
}
}
diff --git a/krpc/krpc-test/src/commonTest/kotlin/kotlinx/rpc/krpc/test/TransportTest.kt b/krpc/krpc-test/src/commonTest/kotlin/kotlinx/rpc/krpc/test/TransportTest.kt
index 6e29b8f46..8897ef491 100644
--- a/krpc/krpc-test/src/commonTest/kotlin/kotlinx/rpc/krpc/test/TransportTest.kt
+++ b/krpc/krpc-test/src/commonTest/kotlin/kotlinx/rpc/krpc/test/TransportTest.kt
@@ -16,16 +16,17 @@ import kotlinx.rpc.krpc.KrpcConfigBuilder
import kotlinx.rpc.krpc.KrpcTransport
import kotlinx.rpc.krpc.client.KrpcClient
import kotlinx.rpc.krpc.internal.KrpcProtocolMessage
-import kotlinx.rpc.krpc.internal.logging.RpcInternalCommonLogger
import kotlinx.rpc.krpc.internal.logging.RpcInternalDumpLogger
import kotlinx.rpc.krpc.internal.logging.RpcInternalDumpLoggerContainer
import kotlinx.rpc.krpc.rpcClientConfig
import kotlinx.rpc.krpc.rpcServerConfig
import kotlinx.rpc.krpc.serialization.json.json
+import kotlinx.rpc.test.runTestWithCoroutinesProbes
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFails
import kotlin.test.assertTrue
+import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
@Rpc
@@ -61,12 +62,20 @@ class TransportTest {
serialization {
json()
}
+
+ connector {
+ waitTimeout = Duration.INFINITE
+ }
}
private val serverConfig = rpcServerConfig {
serialization {
json()
}
+
+ connector {
+ waitTimeout = Duration.INFINITE
+ }
}
private fun clientOf(localTransport: LocalTransport): RpcClient {
@@ -81,12 +90,12 @@ class TransportTest {
return KrpcTestServer(serverConfig, localTransport.server)
}
- private fun runTest(block: suspend TestScope.(logs: List) -> Unit): TestResult =
- kotlinx.coroutines.test.runTest(timeout = 20.seconds) {
- debugCoroutines()
-
- val logger = RpcInternalCommonLogger.logger("TransportTest")
-
+ private fun runTest(
+ timeout: Duration = 120.seconds,
+ times: Int = testIterations,
+ block: suspend TestScope.(logs: List) -> Unit,
+ ): TestResult = runTestWithCoroutinesProbes(timeout = timeout) {
+ repeat(times) {
val logs = mutableListOf()
val logsChannel = Channel(Channel.UNLIMITED)
@@ -102,16 +111,18 @@ class TransportTest {
override fun dump(vararg tags: String, message: () -> String) {
val message = "${tags.joinToString(" ") { "[$it]" }} ${message()}"
logsChannel.trySend(message)
- logger.info { message }
}
})
- block(logs)
-
- RpcInternalDumpLoggerContainer.set(null)
- logsJob.cancelAndJoin()
- logsChannel.close()
+ try {
+ block(logs)
+ } finally {
+ RpcInternalDumpLoggerContainer.set(null)
+ logsJob.cancelAndJoin()
+ logsChannel.close()
+ }
}
+ }
@Test
fun testUsingWrongService() = runTest {
@@ -129,7 +140,9 @@ class TransportTest {
json()
}
- waitForServices = false
+ connector {
+ waitTimeout = dontWait()
+ }
}
server.registerService { EchoImpl() }
@@ -201,7 +214,7 @@ class TransportTest {
}
@Test
- fun testLateConnectWithManyCallsAndClients() = runTest {
+ fun testLateConnectWithManyCallsAndClients() = runTest(timeout = 240.seconds) {
val transports = LocalTransport()
val client = clientOf(transports)
@@ -266,7 +279,7 @@ class TransportTest {
private val configInitialized = atomic(0)
@Test
- fun transportInitializedOnlyOnce() = runTest { logs ->
+ fun transportInitializedOnlyOnce() = runTest(times = 1) { logs ->
val localTransport = LocalTransport()
val client = object : KrpcClient() {
override suspend fun initializeTransport(): KrpcTransport {
@@ -303,3 +316,5 @@ class TransportTest {
return instances
}
}
+
+internal expect val testIterations: Int
diff --git a/krpc/krpc-test/src/commonTest/kotlin/kotlinx/rpc/krpc/test/cancellation/CancellationService.kt b/krpc/krpc-test/src/commonTest/kotlin/kotlinx/rpc/krpc/test/cancellation/CancellationService.kt
index 9ffe82c24..0e32e05c7 100644
--- a/krpc/krpc-test/src/commonTest/kotlin/kotlinx/rpc/krpc/test/cancellation/CancellationService.kt
+++ b/krpc/krpc-test/src/commonTest/kotlin/kotlinx/rpc/krpc/test/cancellation/CancellationService.kt
@@ -4,47 +4,58 @@
package kotlinx.rpc.krpc.test.cancellation
-import kotlinx.atomicfu.atomic
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.rpc.annotations.Rpc
+import kotlinx.rpc.test.WaitCounter
@Rpc
interface CancellationService {
suspend fun longRequest()
- suspend fun serverDelay(millis: Long)
-
suspend fun callException()
+ suspend fun serverCancellation()
+
fun incomingStream(): Flow
+ fun cancellationInIncomingStream(): Flow
+
+ suspend fun cancellationInOutgoingStream(stream: Flow, cancelled: Flow)
+
suspend fun outgoingStream(stream: Flow)
suspend fun outgoingStreamAsync(stream: Flow)
suspend fun outgoingStreamWithDelayedResponse(stream: Flow)
- suspend fun outgoingStreamWithException(stream: Flow)
-
fun nonSuspendable(): Flow
}
class CancellationServiceImpl : CancellationService {
- val delayCounter = atomic(0)
+ val waitCounter = WaitCounter()
+ val successCounter = WaitCounter()
+ val cancellationsCounter = WaitCounter()
+
val consumedIncomingValues = mutableListOf()
val firstIncomingConsumed = CompletableDeferred()
val consumedAll = CompletableDeferred()
val fence = CompletableDeferred()
override suspend fun longRequest() {
- firstIncomingConsumed.complete(0)
- fence.await()
+ try {
+ firstIncomingConsumed.complete(0)
+ waitCounter.increment()
+ fence.await()
+ successCounter.increment()
+ } catch (e: CancellationException) {
+ cancellationsCounter.increment()
+ throw e
+ }
}
- override suspend fun serverDelay(millis: Long) {
- delay(millis)
- delayCounter.incrementAndGet()
+ override suspend fun serverCancellation() {
+ throw CancellationException("serverCancellation")
}
override suspend fun callException() {
@@ -55,6 +66,34 @@ class CancellationServiceImpl : CancellationService {
return resumableFlow(fence)
}
+ override fun cancellationInIncomingStream(): Flow