This is an automated email from the ASF dual-hosted git repository.
He-Pin pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/pekko.git
The following commit(s) were added to refs/heads/main by this push:
new dce94d9a0b Fix ScalaPB generated-message deserialization #3665 (#3665)
dce94d9a0b is described below
commit dce94d9a0b7612a72944640a8a5deac19a69045a
Author: He-Pin(kerr) <[email protected]>
AuthorDate: Fri Oct 23 23:17:08 2026 +0800
Fix ScalaPB generated-message deserialization #3665 (#3665)
Motivation:
The default ScalaPB binding can encode generated messages, but decoding
fails when `parseFrom` returns the erased `GeneratedMessage` type.
Modification:
Resolve the parser's declared return type, require compatibility with the
manifest, and check the actual returned instance while preserving
target-exception wrapping. Cover generated-message round trips, invalid parser
results, binding overrides, and startup without ScalaPB; the dependency is
test-only.
Result:
ScalaPB messages round-trip through the protobuf serializer without
weakening its manifest type contract or requiring ScalaPB at runtime.
Tests:
Validation used a local integration checkout with current main and the
other pending PRs; MiMa ran on this rebased branch.
- `remote / Test / testOnly
org.apache.pekko.remote.serialization.ProtobufSerializerSpec
org.apache.pekko.remote.serialization.ScalaPbSerializationSpec`: 17 passed on
JDK 25; both new parser regressions failed before the fix.
- `+remote/mimaReportBinaryIssues`, `headerCreateAll`, `+headerCheckAll`,
native scalafmt, and `git diff --check`: passed.
- Full validation is delegated to remote CI.
References:
Refs #3651 and
[akka/akka-core#32070](https://github.com/akka/akka-core/pull/32070).
---
project/Dependencies.scala | 4 +-
.../remote/serialization/ProtobufSerializer.scala | 15 ++-
.../serialization/ProtobufSerializerSpec.scala | 37 +++++--
.../serialization/ScalaPbSerializationSpec.scala | 121 +++++++++++++++++++++
4 files changed, 166 insertions(+), 11 deletions(-)
diff --git a/project/Dependencies.scala b/project/Dependencies.scala
index 533da66d57..a4d46c3159 100644
--- a/project/Dependencies.scala
+++ b/project/Dependencies.scala
@@ -184,6 +184,7 @@ object Dependencies {
.exclude("org.testng", "testng")
val protobufRuntime = "com.google.protobuf" % "protobuf-java" %
Protobuf.protobufJavaVersion % Test
+ val scalaPbRuntime = "com.thesamet.scalapb" %% "scalapb-runtime" %
"0.11.21" % Test
// YCSB (Yahoo Cloud Serving Benchmark https://ycsb.site)
val ycsb = "site.ycsb" % "core" % "0.17.0" % Test
@@ -265,7 +266,8 @@ object Dependencies {
TestDependencies.junit,
TestDependencies.scalatest,
TestDependencies.jimfs,
- TestDependencies.protobufRuntime) ++ remoteOptionalDependencies
+ TestDependencies.protobufRuntime,
+ TestDependencies.scalaPbRuntime) ++ remoteOptionalDependencies
lazy val remoteTests = l ++= Seq(TestDependencies.junit,
TestDependencies.scalatest) ++ remoteDependencies
diff --git
a/remote/src/main/scala/org/apache/pekko/remote/serialization/ProtobufSerializer.scala
b/remote/src/main/scala/org/apache/pekko/remote/serialization/ProtobufSerializer.scala
index c45442cbec..d29025b56d 100644
---
a/remote/src/main/scala/org/apache/pekko/remote/serialization/ProtobufSerializer.scala
+++
b/remote/src/main/scala/org/apache/pekko/remote/serialization/ProtobufSerializer.scala
@@ -39,13 +39,24 @@ object ProtobufSerializer {
MethodType.methodType(Void.TYPE, classOf[Throwable]))
private def parsingHandle(clazz: Class[?]): MethodHandle = {
- val methodType = MethodType.methodType(clazz, classOf[Array[Byte]])
+ // ScalaPB's static forwarder can return GeneratedMessage rather than the
concrete message class.
+ val method =
+ try clazz.getDeclaredMethod("parseFrom", classOf[Array[Byte]])
+ catch {
+ case _: NoSuchMethodException => clazz.getMethod("parseFrom",
classOf[Array[Byte]])
+ }
+ val returnType = method.getReturnType
+ require(
+ returnType.isAssignableFrom(clazz),
+ s"parseFrom of [${clazz.getName}] returns unrelated type
[${returnType.getName}]")
+ val methodType = MethodType.methodType(returnType, classOf[Array[Byte]])
val handle =
try lookup.findStatic(clazz, "parseFrom", methodType)
catch {
case _: IllegalAccessException => publicLookup.findStatic(clazz,
"parseFrom", methodType)
}
- val invoker = wrapTargetException(handle).asType(parsingInvokerType)
+ val typedHandle = handle.asType(MethodType.methodType(clazz,
classOf[Array[Byte]]))
+ val invoker = wrapTargetException(typedHandle).asType(parsingInvokerType)
ensureInitialized(clazz)
invoker
}
diff --git
a/remote/src/test/scala/org/apache/pekko/remote/serialization/ProtobufSerializerSpec.scala
b/remote/src/test/scala/org/apache/pekko/remote/serialization/ProtobufSerializerSpec.scala
index 29cc72159f..7921a8d036 100644
---
a/remote/src/test/scala/org/apache/pekko/remote/serialization/ProtobufSerializerSpec.scala
+++
b/remote/src/test/scala/org/apache/pekko/remote/serialization/ProtobufSerializerSpec.scala
@@ -39,6 +39,17 @@ class ThrowingToByteArrayMessage {
def toByteArray(): Array[Byte] = throw new
IllegalArgumentException("serialize-user-bug")
}
+object UnrelatedParseMessage {
+ def parseFrom(@nowarn("msg=never used") bytes: Array[Byte]): String =
"unrelated"
+}
+case class UnrelatedParseMessage() {}
+
+object WrongParseResultMessage {
+ def parseFrom(@nowarn("msg=never used") bytes: Array[Byte]):
ProtobufSerializerSpec.AnotherBase =
+ new AnotherMessage3
+}
+case class WrongParseResultMessage() extends
ProtobufSerializerSpec.AnotherBase {}
+
object ProtobufSerializerSpec {
trait AnotherInterface
abstract class AnotherBase
@@ -76,6 +87,7 @@ class ProtobufSerializerSpec extends PekkoSpec(s"""
"${classOf[AnotherMessage].getName}",
"${classOf[FailingInitializerMessage].getName}",
"${classOf[ThrowingParseMessage].getName}",
+ "${classOf[UnrelatedParseMessage].getName}",
"${classOf[ProtobufSerializerSpec.AnotherInterface].getName}",
"${classOf[ProtobufSerializerSpec.AnotherBase].getName}"
]
@@ -91,14 +103,6 @@ class ProtobufSerializerSpec extends PekkoSpec(s"""
ser.serializerFor(classOf[MyMessageV3]).getClass should
===(classOf[ProtobufSerializer])
}
- "skip the optional ScalaPB binding when ScalaPB is not on the classpath"
in {
- val scalaPbClassName = "scalapb.GeneratedMessage"
- ser.settings.SerializationBindings.get(scalaPbClassName) should
===(Some("proto"))
- val dynamicAccess =
system.asInstanceOf[ExtendedActorSystem].dynamicAccess
- dynamicAccess.getClassFor[Any](scalaPbClassName).isFailure should
===(true)
- ser.bindings.exists { case (clazz, _) => clazz.getName ==
scalaPbClassName } should ===(false)
- }
-
"work for SerializedMessage (just an org.apache.pekko.protobuf message)"
in {
// create a protobuf message
val protobufMessage: SerializedMessage =
@@ -173,6 +177,23 @@ class ProtobufSerializerSpec extends PekkoSpec(s"""
deserialized.getClass should ===(classOf[AnotherMessage3])
}
+ "reject a parser whose declared return type is unrelated to the manifest"
in {
+ val serializer =
ser.serializerFor(classOf[MyMessage]).asInstanceOf[ProtobufSerializer]
+
+ intercept[IllegalArgumentException] {
+ serializer.fromBinary(Array.emptyByteArray,
Some(classOf[UnrelatedParseMessage]))
+ }.getMessage should include("returns unrelated type")
+ }
+
+ "reject a parser result that does not match the manifest and wrap the
target failure" in {
+ val serializer =
ser.serializerFor(classOf[MyMessage]).asInstanceOf[ProtobufSerializer]
+
+ val exception = intercept[InvocationTargetException] {
+ serializer.fromBinary(Array.emptyByteArray,
Some(classOf[WrongParseResultMessage]))
+ }
+ exception.getCause shouldBe a[ClassCastException]
+ }
+
"preserve reflection-compatible target exception wrapping" in {
val serializer =
ser.serializerFor(classOf[MyMessage]).asInstanceOf[ProtobufSerializer]
diff --git
a/remote/src/test/scala/org/apache/pekko/remote/serialization/ScalaPbSerializationSpec.scala
b/remote/src/test/scala/org/apache/pekko/remote/serialization/ScalaPbSerializationSpec.scala
new file mode 100644
index 0000000000..c14c885111
--- /dev/null
+++
b/remote/src/test/scala/org/apache/pekko/remote/serialization/ScalaPbSerializationSpec.scala
@@ -0,0 +1,121 @@
+/*
+ * 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.pekko.remote.serialization
+
+import java.io.File
+import java.nio.file.Files
+import java.util.concurrent.TimeUnit
+
+import scala.concurrent.Await
+import scala.concurrent.duration._
+
+import com.google.protobuf.timestamp.Timestamp
+
+import org.apache.pekko
+import pekko.actor.{ ActorSystem, ExtendedActorSystem }
+import pekko.serialization.{ SerializationExtension, Serializers }
+import pekko.testkit.PekkoSpec
+
+import com.typesafe.config.ConfigFactory
+
+class ScalaPbOverrideSerializer(system: ExtendedActorSystem) extends
ProtobufSerializer(system)
+
+object ScalaPbAbsentClasspath {
+ def main(args: Array[String]): Unit = {
+ require(args.isEmpty)
+
require(scala.util.Try(Class.forName("scalapb.GeneratedMessage")).isFailure,
"ScalaPB must be absent")
+ val system = ActorSystem("without-scalapb")
+ try {
+ val serialization = SerializationExtension(system)
+ require(serialization.deserialize(serialization.serialize("still
works").get, classOf[String]).get ==
+ "still works")
+ println("ActorSystem and serialization started without ScalaPB")
+ } finally Await.result(system.terminate(), 15.seconds)
+ }
+}
+
+class ScalaPbSerializationSpec extends PekkoSpec {
+ private val serialization = SerializationExtension(system)
+ private val message = Timestamp(seconds = 123456789L, nanos = 123456789)
+
+ "Optional ScalaPB serialization" must {
+ "select the default binding and round-trip an actual generated message" in
{
+ serialization.findSerializerFor(message).getClass shouldBe
classOf[ProtobufSerializer]
+ val bytes = serialization.serialize(message).get
+ serialization.deserialize(bytes, classOf[Timestamp]).get shouldBe message
+ val serializer = serialization.findSerializerFor(message)
+ serialization.deserialize(bytes, serializer.identifier,
+ Serializers.manifestFor(serializer, message)).get shouldBe message
+ }
+
+ "honor an application override of the marker binding" in {
+ val config = ConfigFactory.parseString(s"""
+ pekko.actor.serializers.scalapb-custom =
"${classOf[ScalaPbOverrideSerializer].getName}"
+
pekko.actor.serialization-identifiers."${classOf[ScalaPbOverrideSerializer].getName}"
= 987654
+ pekko.actor.serialization-bindings."scalapb.GeneratedMessage" =
scalapb-custom
+ """).withFallback(system.settings.config)
+ val other = ActorSystem("scalapb-override", config)
+ try {
+ val overridden = SerializationExtension(other)
+ overridden.findSerializerFor(message).getClass shouldBe
classOf[ScalaPbOverrideSerializer]
+ overridden.deserialize(overridden.serialize(message).get,
classOf[Timestamp]).get shouldBe message
+ } finally shutdown(other)
+ }
+
+ "honor disabling the marker binding" in {
+ val config = ConfigFactory.parseString("""
+ pekko.actor.serialization-bindings."scalapb.GeneratedMessage" = none
+ """).withFallback(system.settings.config)
+ val other = ActorSystem("scalapb-disabled", config)
+ try {
+ val disabled = SerializationExtension(other)
+ disabled.findSerializerFor(message) should not be a[ProtobufSerializer]
+ disabled.serialize(message).isFailure shouldBe true
+ } finally shutdown(other)
+ }
+
+ "start with a real classpath that does not contain the optional runtime"
in {
+ val classpath =
System.getProperty("java.class.path").split(File.pathSeparator).toVector
+ val runtime = new
File(classOf[scalapb.GeneratedMessage].getProtectionDomain.getCodeSource.getLocation.toURI)
+ classpath.map(new File(_).getCanonicalFile) should
contain(runtime.getCanonicalFile)
+ val withoutRuntime = classpath.filterNot(path => new
File(path).getCanonicalFile == runtime.getCanonicalFile)
+ val output = Files.createTempFile("pekko-without-scalapb-", ".log")
+ val java = new File(new File(System.getProperty("java.home"), "bin"),
"java").getPath
+ val process = new ProcessBuilder(
+ java,
+ "-Xmx256m",
+ "-cp",
+ withoutRuntime.mkString(File.pathSeparator),
+ ScalaPbAbsentClasspath.getClass.getName.stripSuffix("$"))
+ .redirectErrorStream(true)
+ .redirectOutput(output.toFile)
+ .start()
+ try {
+ val exited = process.waitFor(30, TimeUnit.SECONDS)
+ withClue(Files.readString(output)) {
+ exited shouldBe true
+ process.exitValue() shouldBe 0
+ Files.readString(output) should include("ActorSystem and
serialization started without ScalaPB")
+ }
+ } finally {
+ if (process.isAlive) process.destroyForcibly().waitFor(10,
TimeUnit.SECONDS)
+ Files.deleteIfExists(output)
+ }
+ }
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]