Repository navigation
[Bug] Hessian2 breaks Kotlin object singleton identity, silently selecting the wrong when branch #16489
Description
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
- I agree to follow this project's Code of Conduct
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsTodo
Activity
tonjaichuen commentedon Oct 3, 2026
xml-commons/java/external/LICENSE.sax.txt $Id:6765-6096-9611
LI123456mo commentedon Oct 5, 2026
Thnks for neccesarry info @obabichevjb , atleast i reproduced the bug locally, i'll try to look the PR