-
Notifications
You must be signed in to change notification settings - Fork 33
Description
Minimal reproducible example
Shared code:
import kotlinx.rpc.RemoteService
import kotlinx.rpc.annotations.Rpc
@Rpc
interface MyService : RemoteService {
suspend fun function()
}
Server code:
import io.ktor.server.application.*
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
import io.ktor.server.routing.routing
import kotlinx.coroutines.delay
import kotlinx.rpc.krpc.ktor.server.Krpc
import kotlinx.rpc.krpc.ktor.server.rpc
import kotlinx.rpc.krpc.serialization.json.json
import kotlin.coroutines.CoroutineContext
suspend fun doWork(): String {
delay(100)
return "qwerty"
}
class MyServiceImpl(override val coroutineContext: CoroutineContext) : MyService {
override suspend fun function() {
doWork()
}
}
fun main() {
embeddedServer(
Netty,
port = 8080,
host = "0.0.0.0",
module = Application::module
).start(wait = true)
}
fun Application.module() {
install(Krpc) {
serialization {
json()
}
}
routing {
rpc("/") {
registerService<MyService> { ctx -> MyServiceImpl(ctx) }
}
}
}
Client code:
import io.ktor.client.HttpClient
import kotlinx.rpc.krpc.ktor.client.installKrpc
import kotlinx.rpc.krpc.ktor.client.rpc
import kotlinx.rpc.krpc.serialization.json.json
import kotlinx.rpc.withService
suspend fun main() {
val client = HttpClient {
installKrpc {
serialization {
json()
}
}
}
val rpc = client.rpc {
url {
host = "localhost"
port = 8080
}
}
val myService = rpc.withService<MyService>()
myService.function()
}
Actual result on client:
Exception in thread "main" java.lang.ClassCastException: class java.lang.String cannot be cast to class kotlin.Unit (java.lang.String is in module java.base of loader 'bootstrap'; kotlin.Unit is in unnamed module of loader 'app')
at kotlinx.serialization.internal.UnitSerializer.serialize(Primitives.kt:68)
...
Workaround
fun doNothing() = Unit
class MyServiceImpl(override val coroutineContext: CoroutineContext) : MyService {
override suspend fun function() {
doWork()
doNothing() // Prevents the kotlin compiler optimization
}
}
Additional context
This issue is caused by a Kotlin compiler optimization. It seems this might actually be expected behavior, and perhaps the kotlinx.rpc library should handle it accordingly. Here's the source code for the callSuspend function from kotlin.reflect.full:
suspend fun <R> KCallable<R>.callSuspend(vararg args: Any?): R {
if (!this.isSuspend) return call(*args)
if (this !is KFunction<*>) throw IllegalArgumentException("Cannot callSuspend on a property $this: suspend properties are not supported yet")
val result = suspendCoroutineUninterceptedOrReturn<R> { call(*args, it) }
// If suspend function returns Unit and tail-call, it might appear, that it returns not Unit,
// see comment above replaceReturnsUnitMarkersWithPushingUnitOnStack for explanation.
// In this case, return Unit manually.
@Suppress("UNCHECKED_CAST")
if (returnType.classifier == Unit::class && !returnType.isMarkedNullable) return (Unit as R) // !
return result
}
The same behavior can be reproduced with a minimal example outside of kotlinx.rpc:
suspend fun doWork(): String {
delay(100)
return "qwerty"
}
suspend fun function() {
doWork()
}
suspend fun main() {
val function: KFunction<Any?> = ::function
function.call(Continuation(EmptyCoroutineContext) { result: Result<Any?> ->
// Prints "qwerty" (not Unit)
println(result.getOrThrow())
})
delay(1000)
}
This happens because the $completion object is forwarded directly to doWork instead of creating a new Continuation. The decompiled code looks like this:
public static final Object function(@NotNull Continuation $completion) {
Object var10000 = doWork($completion); // <- doWork calls $completion.resume("qwerty") instead of $completion.resume(Unit).
return var10000 == IntrinsicsKt.getCOROUTINE_SUSPENDED() ? var10000 : Unit.INSTANCE;
}
Possible solution
While this may not be the optimal solution (I haven't deeply explored the internals of the library), during my investigation of the issue, I discovered the following:
If you place a breakpoint at line 184 of the KrpcServerService file in the handleCall method where both 'value' and 'returnType' are available and reproduce the error — reveals that:
- 'value' contains "qwerty"
- 'returnType' contains RpcType(typeOf<Unit>())
It might be possible to replace the 'value' with Unit when returnType is Unit, similar to how the kotlin.reflect.full.callSuspend function handles it.
Links
replaceReturnsUnitMarkersWithPushingUnitOnStack -> https://github.com/JetBrains/kotlin/blob/2585221e34435c7229ba13025c4a77e6fada1299/compiler/backend/src/org/jetbrains/kotlin/codegen/coroutines/CoroutineTransformerMethodVisitor.kt#L290