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 3ac83fbd31 fix(stream): prevent double materialization of SourceRef
and SinkRef with clear error (#3114)
3ac83fbd31 is described below
commit 3ac83fbd31dd802c2aa5fb114ab44f5e44486517
Author: He-Pin(kerr) <[email protected]>
AuthorDate: Tue Jun 23 17:18:25 2026 +0800
fix(stream): prevent double materialization of SourceRef and SinkRef with
clear error (#3114)
Motivation:
SourceRefImpl.source and SinkRefImpl.sink() create a new stage instance on
every access. If a user accidentally materializes the returned Source/Sink
more than once, multiple stage instances compete for the same partner
handshake, causing the second materialization to fail with a confusing
error message.
Modification:
Add an AtomicBoolean guard per SourceRef/SinkRef instance. When the
backing stage is materialized, the guard is atomically claimed via
compareAndSet. A second materialization attempt throws a clear
IllegalStateException explaining that SourceRef/SinkRef is single-use,
instead of the previous opaque handshake failure.
Result:
Double materialization of SourceRef or SinkRef now fails fast with a
descriptive error: "This SourceRef/SinkRef has already been materialized.
SourceRef/SinkRef is single-use: calling .source/.sink() and materializing
it more than once is not supported." Concurrent materialization attempts
are also correctly rejected (exactly one succeeds, all others fail).
Tests:
- stream-tests/testOnly org.apache.pekko.stream.scaladsl.StreamRefsSpec:
27/27 passed, including sequential and concurrent (10-thread latch)
double-materialization tests for both SourceRef and SinkRef
References:
Fixes #3106
---
.../pekko/stream/scaladsl/StreamRefsSpec.scala | 63 +++++++++++++++++++---
.../pekko/stream/impl/streamref/SinkRefImpl.scala | 18 ++++++-
.../stream/impl/streamref/SourceRefImpl.scala | 18 ++++++-
3 files changed, 87 insertions(+), 12 deletions(-)
diff --git
a/stream-tests/src/test/scala/org/apache/pekko/stream/scaladsl/StreamRefsSpec.scala
b/stream-tests/src/test/scala/org/apache/pekko/stream/scaladsl/StreamRefsSpec.scala
index 70a006a749..d877cac479 100644
---
a/stream-tests/src/test/scala/org/apache/pekko/stream/scaladsl/StreamRefsSpec.scala
+++
b/stream-tests/src/test/scala/org/apache/pekko/stream/scaladsl/StreamRefsSpec.scala
@@ -17,8 +17,11 @@ import scala.collection.immutable
import scala.concurrent.{ Await, Future }
import scala.concurrent.Promise
import scala.concurrent.duration._
+import scala.util.Try
import scala.util.control.NoStackTrace
+import java.util.concurrent.CountDownLatch
+
import org.apache.pekko
import pekko.{ Done, NotUsed }
import pekko.actor.{ Actor, ActorIdentity, ActorLogging, ActorRef,
ActorSystem, ActorSystemImpl, Identify, Props }
@@ -26,7 +29,6 @@ import pekko.actor.Status.Failure
import pekko.pattern._
import pekko.stream._
import pekko.stream.impl.streamref.{ SinkRefImpl, SourceRefImpl }
-import pekko.stream.testkit.TestPublisher
import pekko.stream.testkit.Utils.TE
import pekko.stream.testkit.scaladsl._
import pekko.testkit.{ PekkoSpec, TestKit, TestProbe }
@@ -480,6 +482,36 @@ class StreamRefsSpec extends
PekkoSpec(StreamRefsSpec.config()) {
expectTerminated(sinkRefStageActorRef)
}
+ "not allow materializing multiple times" in {
+ val remoteProbe = TestProbe()(remoteSystem)
+ remoteActor.tell("give", remoteProbe.ref)
+ val sourceRef = remoteProbe.expectMsgType[SourceRef[String]]
+
+ sourceRef.source.to(Sink.ignore).run()
+
+ val ex = the[IllegalStateException] thrownBy
sourceRef.source.to(Sink.ignore).run()
+ ex.getMessage should include("already been materialized")
+ }
+
+ "not allow concurrent materialization" in {
+ val remoteProbe = TestProbe()(remoteSystem)
+ remoteActor.tell("give", remoteProbe.ref)
+ val sourceRef = remoteProbe.expectMsgType[SourceRef[String]]
+
+ implicit val ec: scala.concurrent.ExecutionContext = system.dispatcher
+ val latch = new CountDownLatch(1)
+ val results = (1 to 10).map { _ =>
+ Future {
+ latch.await()
+ Try(sourceRef.source.to(Sink.ignore).run())
+ }
+ }
+ latch.countDown()
+ val outcomes = Await.result(Future.sequence(results), 10.seconds)
+ outcomes.count(_.isSuccess) shouldBe 1
+ outcomes.count(_.isFailure) shouldBe 9
+ }
+
}
"A SinkRef" must {
@@ -613,15 +645,30 @@ class StreamRefsSpec extends
PekkoSpec(StreamRefsSpec.config()) {
remoteActor.tell(Command("receive", elementProbe.ref), remoteProbe.ref)
val sinkRef = remoteProbe.expectMsgType[SinkRef[String]]
- val p1: TestPublisher.Probe[String] =
TestSource[String]().to(sinkRef).run()
- val p2: TestPublisher.Probe[String] =
TestSource[String]().to(sinkRef).run()
+ TestSource[String]().to(sinkRef).run()
+
+ val ex = the[IllegalStateException] thrownBy
TestSource[String]().to(sinkRef).run()
+ ex.getMessage should include("already been materialized")
+ }
- p1.ensureSubscription()
- p1.expectRequest()
+ "not allow concurrent materialization" in {
+ val remoteProbe = TestProbe()(remoteSystem)
+ val elementProbe = TestProbe()(remoteSystem)
+ remoteActor.tell(Command("receive", elementProbe.ref), remoteProbe.ref)
+ val sinkRef = remoteProbe.expectMsgType[SinkRef[String]]
- // will be cancelled immediately, since it's 2nd:
- p2.ensureSubscription()
- p2.expectCancellation()
+ implicit val ec: scala.concurrent.ExecutionContext = system.dispatcher
+ val latch = new CountDownLatch(1)
+ val results = (1 to 10).map { _ =>
+ Future {
+ latch.await()
+ Try(TestSource[String]().to(sinkRef).run())
+ }
+ }
+ latch.countDown()
+ val outcomes = Await.result(Future.sequence(results), 10.seconds)
+ outcomes.count(_.isSuccess) shouldBe 1
+ outcomes.count(_.isFailure) shouldBe 9
}
}
diff --git
a/stream/src/main/scala/org/apache/pekko/stream/impl/streamref/SinkRefImpl.scala
b/stream/src/main/scala/org/apache/pekko/stream/impl/streamref/SinkRefImpl.scala
index 53b8a89c8d..614a1f8f64 100644
---
a/stream/src/main/scala/org/apache/pekko/stream/impl/streamref/SinkRefImpl.scala
+++
b/stream/src/main/scala/org/apache/pekko/stream/impl/streamref/SinkRefImpl.scala
@@ -27,11 +27,19 @@ import pekko.stream.scaladsl.Sink
import pekko.stream.stage._
import pekko.util.{ OptionVal, PrettyDuration }
+import java.util.concurrent.atomic.AtomicBoolean
+
/** INTERNAL API: Implementation class, not intended to be touched directly by
end-users */
@InternalApi
private[stream] final case class SinkRefImpl[In](initialPartnerRef: ActorRef)
extends SinkRef[In] {
+ private val materialized = new AtomicBoolean(false)
+
+ private[stream] def tryClaimMaterialization(): Boolean =
+ materialized.compareAndSet(false, true)
+
override def sink(): Sink[In, NotUsed] =
- Sink.fromGraph(new
SinkRefStageImpl[In](OptionVal.Some(initialPartnerRef))).mapMaterializedValue(_
=> NotUsed)
+ Sink.fromGraph(new SinkRefStageImpl[In](OptionVal.Some(initialPartnerRef),
this))
+ .mapMaterializedValue(_ => NotUsed)
}
/**
@@ -48,7 +56,9 @@ private[stream] final case class
SinkRefImpl[In](initialPartnerRef: ActorRef) ex
* the ref.
*/
@InternalApi
-private[stream] final class SinkRefStageImpl[In] private[pekko] (val
initialPartnerRef: OptionVal[ActorRef])
+private[stream] final class SinkRefStageImpl[In] private[pekko] (
+ val initialPartnerRef: OptionVal[ActorRef],
+ private val owner: SinkRefImpl[?] = null)
extends GraphStageWithMaterializedValue[SinkShape[In], SourceRef[In]] {
import SinkRefStageImpl.ActorRefStage
@@ -67,6 +77,10 @@ private[stream] final class SinkRefStageImpl[In]
private[pekko] (val initialPart
private[pekko] override def createLogicAndMaterializedValue(
inheritedAttributes: Attributes,
eagerMaterializer: Materializer): (GraphStageLogic, SourceRef[In]) = {
+ if (owner != null && !owner.tryClaimMaterialization())
+ throw new IllegalStateException(
+ "This SinkRef has already been materialized. " +
+ "SinkRef is single-use: calling .sink() and materializing it more than
once is not supported.")
val logic = new TimerGraphStageLogic(shape) with StageLogging with
ActorRefStage with InHandler {
override protected def logSource: Class[?] = classOf[SinkRefStageImpl[?]]
diff --git
a/stream/src/main/scala/org/apache/pekko/stream/impl/streamref/SourceRefImpl.scala
b/stream/src/main/scala/org/apache/pekko/stream/impl/streamref/SourceRefImpl.scala
index 4e392cc35b..59d383c5ad 100644
---
a/stream/src/main/scala/org/apache/pekko/stream/impl/streamref/SourceRefImpl.scala
+++
b/stream/src/main/scala/org/apache/pekko/stream/impl/streamref/SourceRefImpl.scala
@@ -26,11 +26,19 @@ import pekko.stream.scaladsl.Source
import pekko.stream.stage._
import pekko.util.{ OptionVal, PrettyDuration }
+import java.util.concurrent.atomic.AtomicBoolean
+
/** INTERNAL API: Implementation class, not intended to be touched directly by
end-users */
@InternalApi
private[stream] final case class SourceRefImpl[T](initialPartnerRef: ActorRef)
extends SourceRef[T] {
+ private val materialized = new AtomicBoolean(false)
+
+ private[stream] def tryClaimMaterialization(): Boolean =
+ materialized.compareAndSet(false, true)
+
def source: Source[T, NotUsed] =
- Source.fromGraph(new
SourceRefStageImpl(OptionVal.Some(initialPartnerRef))).mapMaterializedValue(_
=> NotUsed)
+ Source.fromGraph(new SourceRefStageImpl(OptionVal.Some(initialPartnerRef),
this))
+ .mapMaterializedValue(_ => NotUsed)
}
/**
@@ -107,7 +115,9 @@ private[stream] final case class
SourceRefImpl[T](initialPartnerRef: ActorRef) e
* If it is none, then we are the side creating the ref.
*/
@InternalApi
-private[stream] final class SourceRefStageImpl[Out](val initialPartnerRef:
OptionVal[ActorRef])
+private[stream] final class SourceRefStageImpl[Out](
+ val initialPartnerRef: OptionVal[ActorRef],
+ private val owner: SourceRefImpl[?] = null)
extends GraphStageWithMaterializedValue[SourceShape[Out], SinkRef[Out]] {
stage =>
import SourceRefStageImpl._
@@ -126,6 +136,10 @@ private[stream] final class SourceRefStageImpl[Out](val
initialPartnerRef: Optio
private[pekko] override def createLogicAndMaterializedValue(
inheritedAttributes: Attributes,
eagerMaterializer: Materializer): (GraphStageLogic, SinkRef[Out]) = {
+ if (owner != null && !owner.tryClaimMaterialization())
+ throw new IllegalStateException(
+ "This SourceRef has already been materialized. " +
+ "SourceRef is single-use: calling .source and materializing it more
than once is not supported.")
val logic = new TimerGraphStageLogic(shape) with StageLogging with
ActorRefStage with OutHandler {
override protected def logSource: Class[?] =
classOf[SourceRefStageImpl[?]]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]