This is an automated email from the ASF dual-hosted git repository.
He-Pin pushed a commit to branch 1.8.x
in repository https://gitbox.apache.org/repos/asf/pekko.git
The following commit(s) were added to refs/heads/1.8.x by this push:
new 5b1b0e5b22 fix: backport mailbox task reuse fix to 1.8.x (#3621,
#3628) (#3627)
5b1b0e5b22 is described below
commit 5b1b0e5b223bd72b4d9f033a00d1a80be1babda0
Author: He-Pin(kerr) <[email protected]>
AuthorDate: Fri Oct 16 03:29:39 2026 +0800
fix: backport mailbox task reuse fix to 1.8.x (#3621, #3628) (#3627)
Motivation:
A reused mailbox must not also be a ForkJoinTask. Preserve the final main
branch fix without changing caller-owned tasks' completion or cancellation.
Modification:
Remove Mailbox's ForkJoinTask inheritance and related methods as on main.
Preserve both 1.x executor implementations and add the #3628 comments and
submission tests for Java 8 and Java 9+, including the test classpath
mapping.
Retain the existing internal MiMa filters and update dispatcher
documentation.
Result:
Mailbox submissions receive fresh wrappers; existing fork-join tasks retain
their completion and cancellation state. Java 8 support is preserved.
Tests:
- Cross-version submission suites: 24 passed on Scala 2.12, 2.13, and 3.
- JDK 8 submission suite: 4 passed after running the whole sbt process on 8.
An initial mixed JDK 17 launcher/JDK 8 fork could not read its argument
file.
- JDK 17 related suites: 192 passed, 2 existing pending tests.
- +mimaReportBinaryIssues and +headerCheckAll passed on all Scala versions.
- docs/paradox passed with java.lang opened on JDK 17; the initial run
without --add-opens failed in the existing parser's reflection code.
- Changed-file scalafmt and git diff --check passed. Full checkCodeStyle
reports only the unchanged NestedPayloadDepthSpec.scala formatting.
- Local validatePullRequest stopped and negative controls not started at
the contributor's request; remaining validation is delegated to remote CI.
- Auditable Qoder stdout review: No must-fix findings.
References:
Refs #3621, #3628, #3620, #3613.
---
.../dispatch/MailboxTaskSubmissionJdk9Spec.scala | 32 ++++++
.../pekko/dispatch/MailboxTaskSubmissionSpec.scala | 128 +++++++++++++++++++++
.../mailbox-not-a-forkjoin-task.excludes | 32 ++++++
.../pekko/dispatch/PekkoJdk9ForkJoinPool.scala | 4 +
.../dispatch/ForkJoinExecutorConfigurator.scala | 4 +
.../scala/org/apache/pekko/dispatch/Mailbox.scala | 29 ++---
build.sbt | 2 +-
docs/src/main/paradox/dispatchers.md | 6 +
8 files changed, 216 insertions(+), 21 deletions(-)
diff --git
a/actor-tests/src/test/scala-jdk9-only/org/apache/pekko/dispatch/MailboxTaskSubmissionJdk9Spec.scala
b/actor-tests/src/test/scala-jdk9-only/org/apache/pekko/dispatch/MailboxTaskSubmissionJdk9Spec.scala
new file mode 100644
index 0000000000..e31555c5ba
--- /dev/null
+++
b/actor-tests/src/test/scala-jdk9-only/org/apache/pekko/dispatch/MailboxTaskSubmissionJdk9Spec.scala
@@ -0,0 +1,32 @@
+/*
+ * 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.dispatch
+
+import java.util.concurrent.ForkJoinPool
+
+/** Run the same submission contract against the executor selected on Java 9
and newer. */
+class MailboxTaskSubmissionJdk9Spec extends MailboxTaskSubmissionSpec {
+ override protected def newPool(): ForkJoinPool =
+ new PekkoJdk9ForkJoinPool(
+ 2,
+ new QuietThreadFactory,
+ ForkJoinPoolConstants.MaxCap,
+ quietHandler,
+ asyncMode = true,
+ minimumRunnable = 1)
+}
diff --git
a/actor-tests/src/test/scala/org/apache/pekko/dispatch/MailboxTaskSubmissionSpec.scala
b/actor-tests/src/test/scala/org/apache/pekko/dispatch/MailboxTaskSubmissionSpec.scala
new file mode 100644
index 0000000000..ea04afd8a3
--- /dev/null
+++
b/actor-tests/src/test/scala/org/apache/pekko/dispatch/MailboxTaskSubmissionSpec.scala
@@ -0,0 +1,128 @@
+/*
+ * 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.dispatch
+
+import java.util.concurrent.{ ForkJoinPool, ForkJoinTask, ForkJoinWorkerThread
}
+import java.util.concurrent.atomic.{ AtomicBoolean, AtomicInteger }
+
+import org.apache.pekko
+import pekko.dispatch.ForkJoinExecutorConfigurator.PekkoForkJoinPool
+import pekko.testkit.PekkoSpec
+
+/**
+ * A Mailbox is one object that its dispatcher resubmits for the actor's whole
lifetime, and
+ * `ForkJoinPool.execute` pushes a `ForkJoinTask` as-is instead of wrapping
it. Letting the mailbox
+ * itself reach the pool therefore puts the same reference back into the same
work-queue slot over
+ * and over, which a steal that stalls between its base recheck and its slot
CAS turns into a
+ * permanently damaged queue, and which one exceptional completion turns into
a mailbox that never
+ * runs again. See https://github.com/ben-manes/caffeine/issues/2021 and
+ * https://github.com/apache/pekko/issues/3620.
+ *
+ * This pins the mailbox's plain Runnable shape and resubmission after a
failing run. Existing
+ * ForkJoinTasks submitted as Runnables must retain their completion and
cancellation semantics.
+ */
+class MailboxTaskSubmissionSpec extends PekkoSpec {
+
+ protected val quietHandler = new Thread.UncaughtExceptionHandler {
+ override def uncaughtException(t: Thread, e: Throwable): Unit = ()
+ }
+
+ protected final class QuietThreadFactory extends
ForkJoinPool.ForkJoinWorkerThreadFactory {
+ override def newThread(pool: ForkJoinPool): ForkJoinWorkerThread = {
+ val thread = new ForkJoinWorkerThread(pool) {}
+ thread.setName("MailboxTaskSubmissionSpec-worker")
+ thread.setUncaughtExceptionHandler(quietHandler)
+ thread
+ }
+ }
+
+ /** Exposes a Runnable entry point so execute(Runnable) can receive an
existing ForkJoinTask. */
+ private final class RunnableForkJoinTask extends ForkJoinTask[Unit] with
Runnable {
+ val runs = new AtomicInteger(0)
+ override def getRawResult(): Unit = ()
+ override def setRawResult(unit: Unit): Unit = ()
+ override def exec(): Boolean = {
+ run()
+ true
+ }
+ override def run(): Unit = {
+ runs.incrementAndGet()
+ }
+ }
+
+ protected def newPool(): ForkJoinPool =
+ new PekkoForkJoinPool(2, new QuietThreadFactory, quietHandler, asyncMode =
true)
+
+ "Mailbox" must {
+
+ "not be a ForkJoinTask" in {
+ classOf[ForkJoinTask[_]].isAssignableFrom(classOf[Mailbox]) shouldBe
false
+ }
+ }
+
+ "PekkoForkJoinPool" must {
+
+ "not run a cancelled ForkJoinTask submitted as a Runnable" in {
+ val pool = newPool()
+ val task = new RunnableForkJoinTask
+ try {
+ task.cancel(false) shouldBe true
+ // Select execute(Runnable), which must preserve the existing task's
cancellation state.
+ pool.execute(task: Runnable)
+ } finally pool.shutdown()
+ pool.awaitTermination(remainingOrDefault.toMillis,
java.util.concurrent.TimeUnit.MILLISECONDS) shouldBe true
+ task.isCancelled shouldBe true
+ task.runs.get() shouldBe 0
+ }
+
+ "complete a ForkJoinTask submitted as a Runnable" in {
+ val pool = newPool()
+ val task = new RunnableForkJoinTask
+ try {
+ pool.execute(task: Runnable)
+ // Bound the wait so a regression fails instead of hanging in join().
+ awaitAssert(task.isDone shouldBe true)
+ task.isCompletedNormally shouldBe true
+ task.join()
+ task.runs.get() shouldBe 1
+ } finally pool.shutdown()
+ }
+
+ // Control: plain Runnables receive a fresh wrapper on every submission,
including before #3621.
+ "run a plain reused Runnable again after one of its runs threw" in {
+ val pool = newPool()
+ try {
+ val runs = new AtomicInteger(0)
+ val firstRunFailed = new AtomicBoolean(false)
+ val task = new Runnable {
+ override def run(): Unit = {
+ runs.incrementAndGet()
+ if (firstRunFailed.compareAndSet(false, true)) throw new
RuntimeException("expected test failure")
+ }
+ }
+
+ pool.execute(task)
+ awaitAssert(runs.get() shouldBe 1)
+ // Let the first wrapper finish failing before checking a subsequent
submission.
+ awaitAssert(pool.isQuiescent shouldBe true)
+ pool.execute(task)
+ awaitAssert(runs.get() shouldBe 2)
+ } finally pool.shutdown()
+ }
+ }
+}
diff --git
a/actor/src/main/mima-filters/1.8.x.backwards.excludes/mailbox-not-a-forkjoin-task.excludes
b/actor/src/main/mima-filters/1.8.x.backwards.excludes/mailbox-not-a-forkjoin-task.excludes
new file mode 100644
index 0000000000..4f8d19577e
--- /dev/null
+++
b/actor/src/main/mima-filters/1.8.x.backwards.excludes/mailbox-not-a-forkjoin-task.excludes
@@ -0,0 +1,32 @@
+# 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.
+
+# Mailbox is no longer a ForkJoinTask. A mailbox is one object that its
dispatcher resubmits for the
+# whole lifetime of the actor, and ForkJoinPool.execute pushes a ForkJoinTask
as-is instead of
+# wrapping it, so the same reference kept returning to the same work-queue
slot. A steal that stalls
+# between its base recheck and its slot CAS can then move the queue's base
backwards and permanently
+# damage the queue (https://github.com/ben-manes/caffeine/issues/2021), and a
single exceptional
+# completion made ForkJoinTask.doExec short-circuit forever, silently
disabling the mailbox
+# (https://github.com/apache/pekko/issues/3620). Mailbox is private[pekko] and
escapes only through
+# MessageDispatcher.createMailbox.
+ProblemFilters.exclude[MissingTypesProblem]("org.apache.pekko.dispatch.Mailbox")
+ProblemFilters.exclude[DirectMissingMethodProblem]("org.apache.pekko.dispatch.Mailbox.getRawResult()Unit")
+ProblemFilters.exclude[DirectMissingMethodProblem]("org.apache.pekko.dispatch.Mailbox.setRawResult(scala.runtime.BoxedUnit)Unit")
+ProblemFilters.exclude[DirectMissingMethodProblem]("org.apache.pekko.dispatch.Mailbox.exec()Boolean")
+
+# The private SharingMailbox subclass is still present in 1.x and inherits the
same internal change.
+ProblemFilters.exclude[MissingTypesProblem]("org.apache.pekko.dispatch.BalancingDispatcher$SharingMailbox")
diff --git
a/actor/src/main/scala-jdk-9/org/apache/pekko/dispatch/PekkoJdk9ForkJoinPool.scala
b/actor/src/main/scala-jdk-9/org/apache/pekko/dispatch/PekkoJdk9ForkJoinPool.scala
index adb49072d0..e8e82595d3 100644
---
a/actor/src/main/scala-jdk-9/org/apache/pekko/dispatch/PekkoJdk9ForkJoinPool.scala
+++
b/actor/src/main/scala-jdk-9/org/apache/pekko/dispatch/PekkoJdk9ForkJoinPool.scala
@@ -43,6 +43,10 @@ private[dispatch] final class PekkoJdk9ForkJoinPool(
override def execute(r: Runnable): Unit =
if (r ne null)
+ // An existing ForkJoinTask is pushed as-is (matching
ForkJoinPool.execute) so that its own
+ // completion and cancellation state is honoured. Callers must not
resubmit the same task
+ // instance (see https://github.com/ben-manes/caffeine/issues/2021);
this is why Mailbox
+ // is deliberately not a ForkJoinTask.
super.execute(
(if (r.isInstanceOf[ForkJoinTask[_]]) r else new
PekkoForkJoinTask(r)).asInstanceOf[ForkJoinTask[Any]])
else
diff --git
a/actor/src/main/scala/org/apache/pekko/dispatch/ForkJoinExecutorConfigurator.scala
b/actor/src/main/scala/org/apache/pekko/dispatch/ForkJoinExecutorConfigurator.scala
index 903641bbb5..f82002db34 100644
---
a/actor/src/main/scala/org/apache/pekko/dispatch/ForkJoinExecutorConfigurator.scala
+++
b/actor/src/main/scala/org/apache/pekko/dispatch/ForkJoinExecutorConfigurator.scala
@@ -66,6 +66,10 @@ object ForkJoinExecutorConfigurator {
override def execute(r: Runnable): Unit =
if (r ne null)
+ // An existing ForkJoinTask is pushed as-is (matching
ForkJoinPool.execute) so that its own
+ // completion and cancellation state is honoured. Callers must not
resubmit the same task
+ // instance (see https://github.com/ben-manes/caffeine/issues/2021);
this is why Mailbox
+ // is deliberately not a ForkJoinTask.
super.execute(
(if (r.isInstanceOf[ForkJoinTask[_]]) r else new
PekkoForkJoinTask(r)).asInstanceOf[ForkJoinTask[Any]])
else
diff --git a/actor/src/main/scala/org/apache/pekko/dispatch/Mailbox.scala
b/actor/src/main/scala/org/apache/pekko/dispatch/Mailbox.scala
index f8df5240a5..b0438bce10 100644
--- a/actor/src/main/scala/org/apache/pekko/dispatch/Mailbox.scala
+++ b/actor/src/main/scala/org/apache/pekko/dispatch/Mailbox.scala
@@ -60,11 +60,18 @@ private[pekko] object Mailbox {
* Mailbox and InternalMailbox is separated in two classes because ActorCell
is needed for implementation,
* but can't be exposed to user defined mailbox subclasses.
*
+ * A Mailbox must not be a `java.util.concurrent.ForkJoinTask`. It is one
object resubmitted for the
+ * whole lifetime of its actor, and `ForkJoinPool.execute` pushes a
`ForkJoinTask` as-is instead of
+ * wrapping it, so the same reference returns to the same work-queue slot
again and again. That lets a
+ * worker stalled mid-steal move the queue's `base` backwards and permanently
damage the queue, and it
+ * lets one exceptional completion disable the mailbox for good.
+ * See https://github.com/ben-manes/caffeine/issues/2021 and
+ * https://github.com/apache/pekko/issues/3620. Pinned by
MailboxTaskSubmissionSpec.
+ *
* INTERNAL API
*/
private[pekko] abstract class Mailbox(val messageQueue: MessageQueue)
- extends ForkJoinTask[Unit]
- with SystemMessageQueue
+ extends SystemMessageQueue
with Runnable {
import Mailbox._
@@ -239,24 +246,6 @@ private[pekko] abstract class Mailbox(val messageQueue:
MessageQueue)
}
}
- override final def getRawResult(): Unit = ()
- override final def setRawResult(unit: Unit): Unit = ()
- final override def exec(): Boolean =
- try {
- run(); false
- } catch {
- case _: InterruptedException =>
- Thread.currentThread().interrupt()
- false
- case anything: Throwable =>
- val t = Thread.currentThread()
- t.getUncaughtExceptionHandler match {
- case null =>
- case some => some.uncaughtException(t, anything)
- }
- throw anything
- }
-
/**
* Process the messages in the mailbox
*/
diff --git a/build.sbt b/build.sbt
index ae85048522..24430c061d 100644
--- a/build.sbt
+++ b/build.sbt
@@ -125,7 +125,7 @@ lazy val actor = pekkoModule("actor")
lazy val actorTests = pekkoModule("actor-tests")
.configs(Jdk9.TestJdk9)
- .dependsOn(testkit % "compile->compile;test->test", actor)
+ .dependsOn(testkit % "compile->compile;test->test", actor %
"compile->compile;TestJdk9->CompileJdk9")
.settings(Dependencies.actorTests)
.enablePlugins(NoPublish, Jdk9)
.disablePlugins(MimaPlugin)
diff --git a/docs/src/main/paradox/dispatchers.md
b/docs/src/main/paradox/dispatchers.md
index 3ee19c2f3e..cdf5a80272 100644
--- a/docs/src/main/paradox/dispatchers.md
+++ b/docs/src/main/paradox/dispatchers.md
@@ -44,6 +44,12 @@ You can read more about parallelism in the JDK's
[ForkJoinPool documentation](ht
When Running on Java 9+, you can use `maximum-pool-size` to set the upper
bound on the total number of threads allocated by the ForkJoinPool.
+Starting with Pekko 1.8.0, mailboxes no longer extend `ForkJoinTask`, so each
mailbox submission to a fork-join executor uses a fresh task wrapper.
+This prevents reusing a mailbox task from corrupting the work queue or leaving
the mailbox unable to run after an exception.
+The fix applies to both the Java 8 and Java 9+ executors and requires no
configuration changes.
+Existing `ForkJoinTask`s submitted as `Runnable`s are still executed directly,
preserving their own completion and cancellation state.
+Callers supplying their own fork-join tasks remain responsible for safe reuse
and reinitialization.
+
In Pekko 1.7.0, we introduced a setting for the `fork-join-executor` called
`minimum-runnable`. In Pekko 1.x, this defaults to `1` for backsward
compatibility reasons.
If you override the default and set it to `-1`, this will raise the number of
compensation threads the
pool will create when workers block, mitigating the asyncMode (FIFO)
compensation regression tracked in
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]