From ff2e5a37b304b1037c823f5850f504e2ff7cb41c Mon Sep 17 00:00:00 2001 From: "Oleg.Babichev" Date: Fri, 2 Oct 2026 13:59:29 +0200 Subject: [PATCH 1/2] fix: preserve Kotlin object singleton identity across Hessian2 deserialization --- .../dubbo-serialization-hessian2/pom.xml | 8 ++ .../hessian2/Hessian2SerializerFactory.java | 50 ++++++++++++ .../hessian2/KotlinObjectDeserializer.java | 77 +++++++++++++++++++ .../hessian2/JavaStyleSingleton.java | 33 ++++++++ .../KotlinObjectDeserializerTest.java | 77 +++++++++++++++++++ .../serialize/hessian2/KotlinStyleObject.java | 35 +++++++++ 6 files changed, 280 insertions(+) create mode 100644 dubbo-serialization/dubbo-serialization-hessian2/src/main/java/org/apache/dubbo/common/serialize/hessian2/KotlinObjectDeserializer.java create mode 100644 dubbo-serialization/dubbo-serialization-hessian2/src/test/java/org/apache/dubbo/common/serialize/hessian2/JavaStyleSingleton.java create mode 100644 dubbo-serialization/dubbo-serialization-hessian2/src/test/java/org/apache/dubbo/common/serialize/hessian2/KotlinObjectDeserializerTest.java create mode 100644 dubbo-serialization/dubbo-serialization-hessian2/src/test/java/org/apache/dubbo/common/serialize/hessian2/KotlinStyleObject.java diff --git a/dubbo-serialization/dubbo-serialization-hessian2/pom.xml b/dubbo-serialization/dubbo-serialization-hessian2/pom.xml index 0342a5e766a4..a57b4e9c1e3a 100644 --- a/dubbo-serialization/dubbo-serialization-hessian2/pom.xml +++ b/dubbo-serialization/dubbo-serialization-hessian2/pom.xml @@ -45,6 +45,14 @@ limitations under the License. log4j-slf4j-impl test + + + org.jetbrains.kotlin + kotlin-stdlib + 2.4.20 + test + org.apache.dubbo dubbo-native diff --git a/dubbo-serialization/dubbo-serialization-hessian2/src/main/java/org/apache/dubbo/common/serialize/hessian2/Hessian2SerializerFactory.java b/dubbo-serialization/dubbo-serialization-hessian2/src/main/java/org/apache/dubbo/common/serialize/hessian2/Hessian2SerializerFactory.java index f907e164445d..96c57f00b2d1 100644 --- a/dubbo-serialization/dubbo-serialization-hessian2/src/main/java/org/apache/dubbo/common/serialize/hessian2/Hessian2SerializerFactory.java +++ b/dubbo-serialization/dubbo-serialization-hessian2/src/main/java/org/apache/dubbo/common/serialize/hessian2/Hessian2SerializerFactory.java @@ -20,6 +20,9 @@ import java.io.InputStream; import java.io.Serializable; +import java.lang.annotation.Annotation; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; import com.alibaba.com.caucho.hessian.io.Deserializer; import com.alibaba.com.caucho.hessian.io.InputStreamDeserializer; @@ -34,6 +37,9 @@ public class Hessian2SerializerFactory extends SerializerFactory { + private static final String KOTLIN_METADATA_ANNOTATION = "kotlin.Metadata"; + private static final String KOTLIN_OBJECT_INSTANCE_FIELD = "INSTANCE"; + private final DefaultSerializeClassChecker defaultSerializeClassChecker; public Hessian2SerializerFactory( @@ -80,6 +86,11 @@ protected Deserializer getDefaultDeserializer(Class cl) { checkSerializable(cl); + Object kotlinObject = kotlinObjectInstance(cl); + if (kotlinObject != null) { + return new KotlinObjectDeserializer(cl, kotlinObject); + } + if (RecordUtil.isRecord(cl)) { return new RecordDeserializer(cl, getFieldDeserializerFactory()); } else { @@ -89,6 +100,45 @@ protected Deserializer getDefaultDeserializer(Class cl) { } } + /** + * Returns the singleton held by a Kotlin {@code object} declaration, or {@code null} if this is + * not one. A Kotlin {@code object} compiles to a final class with a private constructor and a + * {@code public static final INSTANCE} field of its own type. + * + *

The {@code kotlin.Metadata} annotation is matched by name so that Dubbo needs no + * dependency on kotlin-stdlib. Requiring it also keeps the behaviour change scoped to Kotlin: + * a hand-written Java singleton with the same shape continues to deserialize as before. + */ + private static Object kotlinObjectInstance(Class cl) { + if (!isKotlinClass(cl)) { + return null; + } + try { + Field instance = cl.getDeclaredField(KOTLIN_OBJECT_INSTANCE_FIELD); + int modifiers = instance.getModifiers(); + if (!Modifier.isStatic(modifiers) || !Modifier.isFinal(modifiers) || instance.getType() != cl) { + return null; + } + return instance.get(null); + } catch (NoSuchFieldException | IllegalAccessException | RuntimeException e) { + return null; + } + } + + private static boolean isKotlinClass(Class cl) { + try { + for (Annotation annotation : cl.getAnnotations()) { + if (KOTLIN_METADATA_ANNOTATION.equals( + annotation.annotationType().getName())) { + return true; + } + } + } catch (RuntimeException e) { + // Annotations that cannot be resolved are not Kotlin metadata. + } + return false; + } + private void checkSerializable(Class cl) { // If class is Serializable => ok // If class has not implement Serializable diff --git a/dubbo-serialization/dubbo-serialization-hessian2/src/main/java/org/apache/dubbo/common/serialize/hessian2/KotlinObjectDeserializer.java b/dubbo-serialization/dubbo-serialization-hessian2/src/main/java/org/apache/dubbo/common/serialize/hessian2/KotlinObjectDeserializer.java new file mode 100644 index 000000000000..71143280daa1 --- /dev/null +++ b/dubbo-serialization/dubbo-serialization-hessian2/src/main/java/org/apache/dubbo/common/serialize/hessian2/KotlinObjectDeserializer.java @@ -0,0 +1,77 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.dubbo.common.serialize.hessian2; + +import java.io.IOException; + +import com.alibaba.com.caucho.hessian.io.AbstractDeserializer; +import com.alibaba.com.caucho.hessian.io.AbstractHessianInput; + +/** + * Deserializer for a Kotlin {@code object} (and {@code data object}) declaration. + * + *

Kotlin guarantees that such a declaration has exactly one instance per class loader, exposed as + * a {@code public static final INSTANCE} field, and gives it a private constructor so that no other + * instance can be created. The default bean deserializers do not honour that: they allocate a fresh + * instance (bypassing the private constructor) and populate its fields, so a value that crosses the + * wire is no longer reference-equal to the singleton. For a plain {@code object}, whose + * {@code equals} is inherited identity comparison, that also breaks {@code ==} and therefore + * silently changes which branch a Kotlin {@code when} selects. + * + *

This deserializer resolves the declared singleton instead, mirroring how + * {@code EnumDeserializer} resolves an enum constant by name: the serialized field data is read from + * the stream so that the stream position stays correct, then discarded, and the singleton is + * returned. Discarding any transmitted state is deliberate and matches enum behaviour — the identity + * guarantee is the property being preserved. + */ +public class KotlinObjectDeserializer extends AbstractDeserializer { + + private final Class type; + private final Object instance; + + public KotlinObjectDeserializer(Class type, Object instance) { + this.type = type; + this.instance = instance; + } + + @Override + public Class getType() { + return type; + } + + @Override + public Object readMap(AbstractHessianInput in) throws IOException { + while (!in.isEnd()) { + // Read and discard: the singleton's state is authoritative, as for an enum constant. + in.readObject(); + in.readObject(); + } + in.readMapEnd(); + in.addRef(instance); + return instance; + } + + @Override + public Object readObject(AbstractHessianInput in, Object[] fields) throws IOException { + String[] fieldNames = (String[]) fields; + for (int i = 0; i < fieldNames.length; i++) { + in.readObject(); + } + in.addRef(instance); + return instance; + } +} diff --git a/dubbo-serialization/dubbo-serialization-hessian2/src/test/java/org/apache/dubbo/common/serialize/hessian2/JavaStyleSingleton.java b/dubbo-serialization/dubbo-serialization-hessian2/src/test/java/org/apache/dubbo/common/serialize/hessian2/JavaStyleSingleton.java new file mode 100644 index 000000000000..0746e1b33c4d --- /dev/null +++ b/dubbo-serialization/dubbo-serialization-hessian2/src/test/java/org/apache/dubbo/common/serialize/hessian2/JavaStyleSingleton.java @@ -0,0 +1,33 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.dubbo.common.serialize.hessian2; + +import java.io.Serializable; + +/** + * Same shape as {@link KotlinStyleObject} but without {@code kotlin.Metadata}: an ordinary + * hand-written Java singleton. Used to prove the Kotlin handling does not change how these + * deserialize, since silently returning the singleton here would be a behaviour change. + */ +public final class JavaStyleSingleton implements Serializable { + + private static final long serialVersionUID = 1L; + + public static final JavaStyleSingleton INSTANCE = new JavaStyleSingleton(); + + private JavaStyleSingleton() {} +} diff --git a/dubbo-serialization/dubbo-serialization-hessian2/src/test/java/org/apache/dubbo/common/serialize/hessian2/KotlinObjectDeserializerTest.java b/dubbo-serialization/dubbo-serialization-hessian2/src/test/java/org/apache/dubbo/common/serialize/hessian2/KotlinObjectDeserializerTest.java new file mode 100644 index 000000000000..3b6ec970feab --- /dev/null +++ b/dubbo-serialization/dubbo-serialization-hessian2/src/test/java/org/apache/dubbo/common/serialize/hessian2/KotlinObjectDeserializerTest.java @@ -0,0 +1,77 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.dubbo.common.serialize.hessian2; + +import org.apache.dubbo.common.URL; +import org.apache.dubbo.common.serialize.ObjectInput; +import org.apache.dubbo.common.serialize.ObjectOutput; +import org.apache.dubbo.common.serialize.Serialization; +import org.apache.dubbo.rpc.model.FrameworkModel; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * A Kotlin {@code object} declares a singleton, and the default bean deserializers break that + * guarantee by allocating a fresh instance. For a plain {@code object}, whose {@code equals} is + * inherited identity comparison, that also silently changes which branch a Kotlin {@code when} + * selects. + */ +class KotlinObjectDeserializerTest { + + private Object roundTrip(Object value) throws IOException, ClassNotFoundException { + FrameworkModel frameworkModel = new FrameworkModel(); + Serialization serialization = + frameworkModel.getExtensionLoader(Serialization.class).getExtension("hessian2"); + URL url = URL.valueOf("").setScopeModel(frameworkModel); + + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + ObjectOutput objectOutput = serialization.serialize(url, outputStream); + objectOutput.writeObject(value); + objectOutput.flushBuffer(); + + ByteArrayInputStream inputStream = new ByteArrayInputStream(outputStream.toByteArray()); + ObjectInput objectInput = serialization.deserialize(url, inputStream); + return objectInput.readObject(); + } + + @Test + void testKotlinObjectKeepsSingletonIdentity() throws Exception { + Object result = roundTrip(KotlinStyleObject.INSTANCE); + + Assertions.assertSame( + KotlinStyleObject.INSTANCE, + result, + "a Kotlin object must deserialize to its declared INSTANCE, not a new instance"); + } + + @Test + void testJavaSingletonBehaviourIsUnchanged() throws Exception { + Object result = roundTrip(JavaStyleSingleton.INSTANCE); + + Assertions.assertInstanceOf(JavaStyleSingleton.class, result); + Assertions.assertNotSame( + JavaStyleSingleton.INSTANCE, + result, + "an ordinary Java singleton must keep deserializing to a new instance; " + + "the Kotlin handling is gated on kotlin.Metadata to avoid changing this"); + } +} diff --git a/dubbo-serialization/dubbo-serialization-hessian2/src/test/java/org/apache/dubbo/common/serialize/hessian2/KotlinStyleObject.java b/dubbo-serialization/dubbo-serialization-hessian2/src/test/java/org/apache/dubbo/common/serialize/hessian2/KotlinStyleObject.java new file mode 100644 index 000000000000..8ae5ce244424 --- /dev/null +++ b/dubbo-serialization/dubbo-serialization-hessian2/src/test/java/org/apache/dubbo/common/serialize/hessian2/KotlinStyleObject.java @@ -0,0 +1,35 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.dubbo.common.serialize.hessian2; + +import java.io.Serializable; + +/** + * Byte-compatible stand-in for a Kotlin {@code object} declaration: final class, private + * constructor, {@code public static final INSTANCE} field of its own type, and the + * {@code kotlin.Metadata} annotation the Kotlin compiler emits. Written in Java so that the module + * needs no Kotlin compiler; the detection only inspects these bytecode features. + */ +@kotlin.Metadata +public final class KotlinStyleObject implements Serializable { + + private static final long serialVersionUID = 1L; + + public static final KotlinStyleObject INSTANCE = new KotlinStyleObject(); + + private KotlinStyleObject() {} +} From 140d4103471d7b0ba69678267139c7b48ab78483 Mon Sep 17 00:00:00 2001 From: "Oleg.Babichev" Date: Fri, 2 Oct 2026 23:27:40 +0200 Subject: [PATCH 2/2] fix: improve code coverage --- .../KotlinObjectDeserializerTest.java | 130 ++++++++++++++++-- .../serialize/hessian2/KotlinStyleObject.java | 13 ++ 2 files changed, 131 insertions(+), 12 deletions(-) diff --git a/dubbo-serialization/dubbo-serialization-hessian2/src/test/java/org/apache/dubbo/common/serialize/hessian2/KotlinObjectDeserializerTest.java b/dubbo-serialization/dubbo-serialization-hessian2/src/test/java/org/apache/dubbo/common/serialize/hessian2/KotlinObjectDeserializerTest.java index 3b6ec970feab..0e7beaa4bb1e 100644 --- a/dubbo-serialization/dubbo-serialization-hessian2/src/test/java/org/apache/dubbo/common/serialize/hessian2/KotlinObjectDeserializerTest.java +++ b/dubbo-serialization/dubbo-serialization-hessian2/src/test/java/org/apache/dubbo/common/serialize/hessian2/KotlinObjectDeserializerTest.java @@ -20,12 +20,18 @@ import org.apache.dubbo.common.serialize.ObjectInput; import org.apache.dubbo.common.serialize.ObjectOutput; import org.apache.dubbo.common.serialize.Serialization; +import org.apache.dubbo.common.utils.DefaultSerializeClassChecker; import org.apache.dubbo.rpc.model.FrameworkModel; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.io.Serializable; +import java.util.Arrays; +import java.util.function.Consumer; +import com.alibaba.com.caucho.hessian.io.Hessian2Input; +import com.alibaba.com.caucho.hessian.io.Hessian2Output; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -37,20 +43,50 @@ */ class KotlinObjectDeserializerTest { - private Object roundTrip(Object value) throws IOException, ClassNotFoundException { - FrameworkModel frameworkModel = new FrameworkModel(); - Serialization serialization = - frameworkModel.getExtensionLoader(Serialization.class).getExtension("hessian2"); - URL url = URL.valueOf("").setScopeModel(frameworkModel); - - ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - ObjectOutput objectOutput = serialization.serialize(url, outputStream); - objectOutput.writeObject(value); + /** A Kotlin class that is not an object declaration: metadata present, but no INSTANCE field. */ + @kotlin.Metadata + static final class KotlinStyleClass implements Serializable { + private static final long serialVersionUID = 1L; + private String value = "v"; + } + + /** Metadata present and an INSTANCE field, but not of this class's own type. */ + @kotlin.Metadata + static final class KotlinStyleForeignInstance implements Serializable { + private static final long serialVersionUID = 1L; + public static final String INSTANCE = "not-a-self-reference"; + private String value = "v"; + } + + private byte[] write(Consumer body) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ObjectOutput objectOutput = serialization().serialize(url(), out); + body.accept(objectOutput); objectOutput.flushBuffer(); + return out.toByteArray(); + } + + private ObjectInput read(byte[] bytes) throws IOException { + return serialization().deserialize(url(), new ByteArrayInputStream(bytes)); + } + + private Object roundTrip(Object value) throws IOException, ClassNotFoundException { + return read(write(o -> { + try { + o.writeObject(value); + } catch (IOException e) { + throw new IllegalStateException(e); + } + })) + .readObject(); + } - ByteArrayInputStream inputStream = new ByteArrayInputStream(outputStream.toByteArray()); - ObjectInput objectInput = serialization.deserialize(url, inputStream); - return objectInput.readObject(); + private Serialization serialization() { + return new FrameworkModel().getExtensionLoader(Serialization.class).getExtension("hessian2"); + } + + private URL url() { + return URL.valueOf("").setScopeModel(new FrameworkModel()); } @Test @@ -63,6 +99,62 @@ void testKotlinObjectKeepsSingletonIdentity() throws Exception { "a Kotlin object must deserialize to its declared INSTANCE, not a new instance"); } + /** + * The singleton is authoritative, as an enum constant is: its serialized field data is read so + * that the stream stays aligned, then discarded rather than written back over shared state. + */ + @Test + void testTransmittedStateIsDiscardedAndStreamStaysAligned() throws Exception { + KotlinStyleObject.INSTANCE.setState("written-to-the-wire"); + byte[] bytes = write(out -> { + try { + out.writeObject(KotlinStyleObject.INSTANCE); + out.writeUTF("sentinel"); + } catch (IOException e) { + throw new IllegalStateException(e); + } + }); + KotlinStyleObject.INSTANCE.setState("changed-after-writing"); + + ObjectInput in = read(bytes); + Object first = in.readObject(); + + Assertions.assertSame(KotlinStyleObject.INSTANCE, first); + Assertions.assertEquals( + "changed-after-writing", + KotlinStyleObject.INSTANCE.getState(), + "the singleton's own state must win; transmitted state is discarded"); + Assertions.assertEquals( + "sentinel", + in.readUTF(), + "the serialized fields must still be consumed, or the next value in the stream desyncs"); + } + + /** + * A peer may send the map encoding rather than the field encoding — Hessian2Input dispatches + * those to readMap, and the inherited implementation rejects them. The singleton must be + * resolved there too. + */ + @Test + void testKotlinObjectViaMapEncoding() throws Exception { + Hessian2SerializerFactory factory = + new Hessian2SerializerFactory(getClass().getClassLoader(), DefaultSerializeClassChecker.getInstance()); + + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + Hessian2Output out = new Hessian2Output(bytes); + out.setSerializerFactory(factory); + out.writeMapBegin(KotlinStyleObject.class.getName()); + out.writeString("state"); + out.writeString("written-to-the-wire"); + out.writeMapEnd(); + out.flush(); + + Hessian2Input in = new Hessian2Input(new ByteArrayInputStream(bytes.toByteArray())); + in.setSerializerFactory(factory); + + Assertions.assertSame(KotlinStyleObject.INSTANCE, in.readObject()); + } + @Test void testJavaSingletonBehaviourIsUnchanged() throws Exception { Object result = roundTrip(JavaStyleSingleton.INSTANCE); @@ -74,4 +166,18 @@ void testJavaSingletonBehaviourIsUnchanged() throws Exception { "an ordinary Java singleton must keep deserializing to a new instance; " + "the Kotlin handling is gated on kotlin.Metadata to avoid changing this"); } + + /** + * Kotlin emits its metadata annotation on every class, not only on object declarations, so the + * shape of the INSTANCE field decides. Neither of these is a singleton. + */ + @Test + void testKotlinClassesThatAreNotObjectDeclarations() throws Exception { + for (Object value : Arrays.asList(new KotlinStyleClass(), new KotlinStyleForeignInstance())) { + Object result = roundTrip(value); + + Assertions.assertInstanceOf(value.getClass(), result); + Assertions.assertNotSame(value, result, value.getClass().getSimpleName() + " is not an object declaration"); + } + } } diff --git a/dubbo-serialization/dubbo-serialization-hessian2/src/test/java/org/apache/dubbo/common/serialize/hessian2/KotlinStyleObject.java b/dubbo-serialization/dubbo-serialization-hessian2/src/test/java/org/apache/dubbo/common/serialize/hessian2/KotlinStyleObject.java index 8ae5ce244424..e9b8c64e523c 100644 --- a/dubbo-serialization/dubbo-serialization-hessian2/src/test/java/org/apache/dubbo/common/serialize/hessian2/KotlinStyleObject.java +++ b/dubbo-serialization/dubbo-serialization-hessian2/src/test/java/org/apache/dubbo/common/serialize/hessian2/KotlinStyleObject.java @@ -23,6 +23,9 @@ * constructor, {@code public static final INSTANCE} field of its own type, and the * {@code kotlin.Metadata} annotation the Kotlin compiler emits. Written in Java so that the module * needs no Kotlin compiler; the detection only inspects these bytecode features. + * + *

Carries one instance field, because a Kotlin {@code object} may hold properties and the + * deserializer has to read that serialized field data to keep the stream position correct. */ @kotlin.Metadata public final class KotlinStyleObject implements Serializable { @@ -31,5 +34,15 @@ public final class KotlinStyleObject implements Serializable { public static final KotlinStyleObject INSTANCE = new KotlinStyleObject(); + private String state = "initial"; + private KotlinStyleObject() {} + + public String getState() { + return state; + } + + public void setState(String state) { + this.state = state; + } }