Skip to content

[Bug] Hessian2 breaks Kotlin object singleton identity, silently selecting the wrong when branch #16489

Open

Description

@obabichevjb

Pre-check

  • I am sure that all the content I provide is in English.

Search before asking

  • I had searched in the issues and found no similar issues.

Apache Dubbo Component

Java SDK (apache/dubbo)

Dubbo Version

Dubbo Java 3.3.6, OpenJDK 21 (Amazon Corretto 21.0.5), macOS 15.6 (OS-independent)
Kotlin 2.4.20, Gradle 9.5, default hessian2 serialization

Steps to reproduce this issue

A Kotlin project with one Dubbo dependency. Provider and consumer run in the same JVM over a real
Netty dubbo:// transport with a direct URL and RegistryConfig("N/A"), so no registry or other
infrastructure is needed — and because the transport is real rather than injvm://, values are
genuinely serialized.

build.gradle.kts:

plugins {
    kotlin("jvm") version "2.4.20"
    application
}
repositories { mavenCentral() }
dependencies {
    implementation("org.apache.dubbo:dubbo:3.3.6")
    implementation("io.netty:netty-all:4.1.118.Final")
    implementation("org.javassist:javassist:3.30.2-GA")
    implementation("org.slf4j:slf4j-simple:2.0.13")
}
kotlin { jvmToolchain(21) }
application { mainClass = "repro.MainKt" }

src/main/resources/security/serialize.allowlist:

repro

src/main/kotlin/Main.kt:

package repro

import java.io.Serializable
import org.apache.dubbo.config.*
import org.apache.dubbo.config.bootstrap.DubboBootstrap

sealed interface Reply : Serializable

data class Found(val text: String) : Reply

object NotFoundObject : Reply          // equals() is inherited identity comparison
data object NotFoundDataObject : Reply // compiler generates equals()
enum class NotFoundEnum : Reply { INSTANCE }

interface ReplyService {
    fun roundTrip(reply: Reply): Reply // returns its argument unchanged
}

class ReplyServiceImpl : ReplyService {
    override fun roundTrip(reply: Reply): Reply = reply
}

/** The dispatch a consumer actually writes. */
private fun describe(reply: Reply): String = when (reply) {
    NotFoundObject -> "matched NotFoundObject"
    NotFoundDataObject -> "matched NotFoundDataObject"
    NotFoundEnum.INSTANCE -> "matched NotFoundEnum.INSTANCE"
    is Found -> "matched Found(${reply.text})"
    else -> ">>> FELL THROUGH TO else <<<"
}

private fun report(label: String, sent: Reply, back: Reply) =
    println("%-12s | ===:%-5s | ==:%-5s | %s".format(label, back === sent, back == sent, describe(back)))

fun main() {
    DubboBootstrap.getInstance()
        .application(ApplicationConfig("repro"))
        .registry(RegistryConfig("N/A"))
        .protocol(ProtocolConfig("dubbo", 20881))
        .service(ServiceConfig<ReplyService>().apply {
            setInterface(ReplyService::class.java)
            setRef(ReplyServiceImpl())
        })
        .start()

    val svc = ReferenceConfig<ReplyService>().apply {
        setInterface(ReplyService::class.java)
        url = "dubbo://127.0.0.1:20881"
    }.get()

    report("object", NotFoundObject, svc.roundTrip(NotFoundObject))
    report("data object", NotFoundDataObject, svc.roundTrip(NotFoundDataObject))
    report("enum", NotFoundEnum.INSTANCE, svc.roundTrip(NotFoundEnum.INSTANCE))
    report("data class", Found("hi"), svc.roundTrip(Found("hi")))

    DubboBootstrap.getInstance().destroy()
}

./gradlew run prints:

object       | ===:false | ==:false | >>> FELL THROUGH TO else <<<
data object  | ===:false | ==:true  | matched NotFoundDataObject
enum         | ===:true  | ==:true  | matched NotFoundEnum.INSTANCE
data class   | ===:false | ==:true  | matched Found(hi)

What you expected to happen

Expected: NotFoundObject should arrive at the consumer as the singleton, so the
NotFoundObject -> branch runs — the same way an enum constant survives today.

Actual: it arrives as a different instance of the same class, the when matches nothing, and
control reaches else. No exception, no warning, no log line — only the wrong code path.

Kotlin's object declares a singleton: the compiler emits a final class with a private
constructor and a single public static final INSTANCE, and the language guarantees no other
instance exists. The default bean deserializers do not honour that — they allocate a fresh instance,
bypassing the private constructor, and populate its fields. So Dubbo produces an instance the
language says cannot exist.

Because a plain object inherits equals from Any (identity comparison), losing identity also
loses equality, and Kotlin's when compares with ==. That is why dispatch silently changes.

Hessian2 already solves exactly this problem for the Java equivalent. EnumDeserializer writes the
constant's name and resolves the real JVM constant on the way back, which is why the enum row
above is intact including ===. A Kotlin object is just a class with a static INSTANCE and gets
no such treatment.

Anything else

No response

Do you have a (mini) reproduction demo?

  • Yes, I have a minimal reproduction demo to help resolve this issue more effectively!

Are you willing to submit a pull request to fix on your own?

  • Yes I am willing to submit a pull request on my own!

Code of Conduct

Activity

  1. tonjaichuen commented on Oct 3, 2026

    @tonjaichuen

    xml-commons/java/external/LICENSE.sax.txt $Id:6765-6096-9611

  2. LI123456mo commented on Oct 5, 2026

    @LI123456mo
    Contributor

    Thnks for neccesarry info @obabichevjb , atleast i reproduced the bug locally, i'll try to look the PR

    Image
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions