Skip to content

ClassCastException due to Kotlin optimization #315

@y9maly

Description

@y9maly

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

callSuspend -> https://github.com/JetBrains/kotlin/blob/9fddbb3f514124e980f8c691d38ecc6b0b9301c2/core/reflection.jvm/src/kotlin/reflect/full/KCallables.kt#L53

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions