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 fea9938cfa fix: prevent recursive dispatcher failure reporting #3613
(#3622)
fea9938cfa is described below
commit fea9938cfaed523fe32a69f5cefb5af46026c373
Author: He-Pin(kerr) <[email protected]>
AuthorDate: Tue Oct 20 11:03:17 2026 +0800
fix: prevent recursive dispatcher failure reporting #3613 (#3622)
Motivation:
A dispatcher rejection can recursively publish errors through a logging
actor whose own dispatch is rejected, eventually overflowing the stack.
Modification:
Guard all seven dispatch failure-reporting sites per thread across
dispatchers and event streams. Preserve the outer EventStream report and
print nested failures through StandardOutLogger, releasing the guard in
finally.
Cover rejection, recovery, reentrancy, exceptional cleanup and concurrent
reporting. Wait for complete executor tasks before forcing rejection, and
always release and join reporter threads. Verify that 16 overlapping
Console.withOut scopes capture only their own nested reports without
changing the main thread's Console.out or global System.out.
Clarify emergency stdout behavior and OFF wording in both logging guides.
Result:
Rejected logging dispatch no longer causes unbounded reporting recursion.
Public signatures, retry behavior and exception propagation are unchanged.
Mailbox stranding after double rejection remains a separate issue.
Tests:
- JDK 17: focused spec 5/5 on Scala 2.13.18 and 3.3.8.
- Related dispatcher/mailbox/actor system/logger suites: 209 passed,
with 2 existing pending tests.
- JDK 21: focused spec 5/5; virtual-thread suites 6/6.
- Cross-Scala MiMa, headers, code style, native scalafmt, docs/paradox,
and git diff --check passed.
- validatePullRequest: actor-tests passed 1631 ScalaTest tests, with
1 ignored and 3 pending. Persistence then hit an incompatible x86
LevelDB JNI library on ARM64 (test JVM exit 255). Remaining validation
was interrupted after that environment failure; full validation did
not pass locally. Final-revision GitHub CI remains pending.
- Auditable Qoder stdout review and source-based reassessment completed: No
must-fix findings. All four initial findings were withdrawn. Independent review
also found no must-fix issues.
References:
Refs #3613
---
.../dispatch/DispatchFailureReportingSpec.scala | 289 +++++++++++++++++++++
.../org/apache/pekko/actor/dungeon/Dispatch.scala | 8 +-
.../apache/pekko/dispatch/AbstractDispatcher.scala | 41 ++-
.../org/apache/pekko/dispatch/Dispatcher.scala | 8 +-
docs/src/main/paradox/logging.md | 12 +-
docs/src/main/paradox/typed/logging.md | 12 +-
6 files changed, 355 insertions(+), 15 deletions(-)
diff --git
a/actor-tests/src/test/scala/org/apache/pekko/dispatch/DispatchFailureReportingSpec.scala
b/actor-tests/src/test/scala/org/apache/pekko/dispatch/DispatchFailureReportingSpec.scala
new file mode 100644
index 0000000000..5955310ee6
--- /dev/null
+++
b/actor-tests/src/test/scala/org/apache/pekko/dispatch/DispatchFailureReportingSpec.scala
@@ -0,0 +1,289 @@
+/*
+ * 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.{
+ CountDownLatch,
+ ExecutorService,
+ LinkedBlockingQueue,
+ RejectedExecutionException,
+ ThreadFactory,
+ ThreadPoolExecutor,
+ TimeUnit
+}
+import java.util.concurrent.atomic.{ AtomicBoolean, AtomicInteger }
+
+import scala.annotation.nowarn
+
+import org.apache.pekko
+import pekko.actor._
+import pekko.event.{ EventStream, Logging }
+import pekko.event.Logging.Error
+import pekko.testkit.{ PekkoSpec, TestProbe }
+
+import com.typesafe.config.{ Config, ConfigFactory }
+
+object RejectingExecutorConfigurator {
+
+ /** Flipped by the spec to make every submission fail, the way a shut down
or saturated executor does. */
+ val rejecting = new AtomicBoolean(false)
+ val pendingTasks = new AtomicInteger(0)
+}
+
+/**
+ * An executor that can be switched into rejecting every task, so a spec can
reproduce the
+ * `RejectedExecutionException` that a dispatcher hits during shutdown without
waiting for one.
+ */
+class RejectingExecutorConfigurator(
+ @nowarn("msg=never used") config: Config,
+ @nowarn("msg=never used") prerequisites: DispatcherPrerequisites)
+ extends ExecutorServiceConfigurator(config, prerequisites) {
+
+ override def createExecutorServiceFactory(id: String, threadFactory:
ThreadFactory): ExecutorServiceFactory =
+ new ExecutorServiceFactory {
+ override def createExecutorService: ExecutorService = {
+ val underlying = new ThreadPoolExecutor(
+ 4,
+ 4,
+ 60L,
+ TimeUnit.SECONDS,
+ new LinkedBlockingQueue[Runnable](),
+ threadFactory)
+ new ExecutorServiceDelegate {
+ override def executor: ExecutorService = underlying
+ override def execute(command: Runnable): Unit =
+ if (RejectingExecutorConfigurator.rejecting.get())
+ throw new RejectedExecutionException("test executor is
rejecting")
+ else {
+ RejectingExecutorConfigurator.pendingTasks.incrementAndGet()
+ try executor.execute(() => {
+ try command.run()
+ finally
RejectingExecutorConfigurator.pendingTasks.decrementAndGet()
+ })
+ catch {
+ case e: RejectedExecutionException =>
+ RejectingExecutorConfigurator.pendingTasks.decrementAndGet()
+ throw e
+ }
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Reporting a dispatch failure publishes to the EventStream, which delivers
to a logging actor with
+ * `!` and so goes back through dispatch. When the executor rejects, that
re-enters the failure path
+ * and recurses until the stack overflows, which
`pekko.jvm-exit-on-fatal-error` turns into a JVM
+ * kill. See https://github.com/apache/pekko/issues/3613.
+ */
+class DispatchFailureReportingSpec
+ extends PekkoSpec(
+ ConfigFactory
+ .parseString(s"""
+ pekko {
+ jvm-exit-on-fatal-error = off
+ loggers = ["org.apache.pekko.event.Logging$$DefaultLogger"]
+ loglevel = "ERROR"
+ actor.default-dispatcher.executor =
"org.apache.pekko.dispatch.RejectingExecutorConfigurator"
+ }
+ """)
+ .withFallback(PekkoSpec.testConf)) {
+
+ private def echoActor(probe: TestProbe): ActorRef = {
+ val ref = system.actorOf(Props(new Actor {
+ override def receive: Receive = { case msg => probe.ref ! msg }
+ }))
+ // A reply can arrive while the mailbox is still Scheduled. Even after it
becomes idle,
+ // Mailbox.run can reschedule it, so wait until the executor has finished
the whole task.
+ ref ! "warmup"
+ probe.expectMsg("warmup")
+ awaitAssert(RejectingExecutorConfigurator.pendingTasks.get() shouldBe 0)
+ ref
+ }
+
+ private def errorSubscriber(onError: => Unit): MinimalActorRef = new
MinimalActorRef {
+ override def path: ActorPath = RootActorPath(Address("pekko",
"all-systems"), "/errorSubscriber")
+ override def provider: ActorRefProvider = throw new
UnsupportedOperationException("test ref")
+ override def !(message: Any)(implicit sender: ActorRef): Unit = onError
+ }
+
+ "A message sent while the executor is rejecting" must {
+
+ "not recurse into a StackOverflowError" in {
+ val probe = TestProbe()
+ val ref = echoActor(probe)
+ val published = new AtomicInteger(0)
+ val logger = echoActor(TestProbe())
+ val counter = errorSubscriber {
+ published.incrementAndGet()
+ logger ! "log error"
+ }
+ val output = new java.io.ByteArrayOutputStream()
+ // Forward through a synchronous ref so EventStreamUnsubscriber watches
that ref rather
+ // than asynchronously sending Watch to the idle actor whose dispatch
must be rejected.
+ system.eventStream.subscribe(counter, classOf[Logging.Error])
+ awaitAssert(RejectingExecutorConfigurator.pendingTasks.get() shouldBe 0)
+
+ RejectingExecutorConfigurator.rejecting.set(true)
+ try {
+ // Before the guard, the rejection report published to the logger
actor, whose own dispatch
+ // was rejected by the same executor, whose report published again,
all on this thread.
+ // Console.withOut scopes Scala stdout to this thread; the fallback
logs synchronously.
+ Console.withOut(output) { ref ! "hello" }
+ } catch {
+ case e: StackOverflowError =>
+ fail("reporting the rejected execution recursed until the stack
overflowed", e)
+ } finally {
+ RejectingExecutorConfigurator.rejecting.set(false)
+ system.eventStream.unsubscribe(counter)
+ }
+
+ // The send above really was rejected and reported, so the assertion is
not vacuous.
+ published.get() should be > 0
+ output.toString("UTF-8") should include("registerForExecution was
rejected twice!")
+ }
+
+ "still dispatch normally once the executor accepts again" in {
+ val rejectedProbe = TestProbe()
+ val rejected = echoActor(rejectedProbe)
+
+ RejectingExecutorConfigurator.rejecting.set(true)
+ try rejected ! "hello"
+ finally RejectingExecutorConfigurator.rejecting.set(false)
+
+ // A fresh actor, so this does not depend on when the rejected message
above is drained.
+ val probe = TestProbe()
+ val fresh = system.actorOf(Props(new Actor {
+ override def receive: Receive = { case msg => probe.ref ! msg }
+ }))
+
+ fresh ! "delivered"
+ probe.expectMsg("delivered")
+ }
+ }
+
+ "DispatchFailureReporting" must {
+
+ "send a nested failure to stdout even when it crosses event streams" in {
+ val eventStream = new EventStream(system, false)
+ val nestedEventStream = new EventStream(system, false)
+ val deliveries = new AtomicInteger(0)
+ val nestedDeliveries = new AtomicInteger(0)
+ val output = new java.io.ByteArrayOutputStream()
+ val reentrant = errorSubscriber {
+ deliveries.incrementAndGet()
+ DispatchFailureReporting.report(
+ nestedEventStream,
+ Error(Error.NoCause, "test", getClass, "nested failure"))
+ }
+ eventStream.subscribe(reentrant, classOf[Error])
+ nestedEventStream.subscribe(errorSubscriber {
nestedDeliveries.incrementAndGet(); () }, classOf[Error])
+
+ // Both reports and their nested stdout writes run synchronously on this
thread.
+ Console.withOut(output) {
+ DispatchFailureReporting.report(eventStream, Error(Error.NoCause,
"test", getClass, "outer failure"))
+ deliveries.get() shouldBe 1
+ nestedDeliveries.get() shouldBe 0
+
+ // A second outer report on this thread must reach the bus after the
guard is released.
+ DispatchFailureReporting.report(eventStream, Error(Error.NoCause,
"test", getClass, "second outer failure"))
+ deliveries.get() shouldBe 2
+ nestedDeliveries.get() shouldBe 0
+ }
+ output.toString("UTF-8").linesIterator.count(_.contains("nested
failure")) shouldBe 2
+ }
+
+ "release the guard when an EventStream subscriber throws" in {
+ val eventStream = new EventStream(system, false)
+ val failure = new IllegalStateException("subscriber failed")
+ val subscriber = errorSubscriber { throw failure }
+ eventStream.subscribe(subscriber, classOf[Error])
+ val event = Error(Error.NoCause, "test", getClass, "outer failure")
+
+ val caught = intercept[IllegalStateException] {
+ DispatchFailureReporting.report(eventStream, event)
+ }
+ (caught should be).theSameInstanceAs(failure)
+
+ eventStream.unsubscribe(subscriber)
+ val deliveries = new AtomicInteger(0)
+ eventStream.subscribe(errorSubscriber { deliveries.incrementAndGet(); ()
}, classOf[Error])
+ DispatchFailureReporting.report(eventStream, event)
+ deliveries.get() shouldBe 1
+ }
+
+ "keep concurrent reports and their nested stdout output isolated" in {
+ val eventStream = new EventStream(system, false)
+ val threadCount = 16
+ val entered = new CountDownLatch(threadCount)
+ val proceed = new CountDownLatch(1)
+ val done = new CountDownLatch(threadCount)
+ val deliveries = new AtomicInteger(0)
+ val subscriber = errorSubscriber {
+ deliveries.incrementAndGet()
+ entered.countDown()
+ proceed.await()
+ DispatchFailureReporting.report(
+ eventStream,
+ Error(Error.NoCause, "test", getClass, s"nested failure on
${Thread.currentThread().getName}"))
+ }
+ eventStream.subscribe(subscriber, classOf[Error])
+ val originalConsoleOut = Console.out
+ val originalSystemOut = System.out
+ val outputs = Vector.fill(threadCount)(new
java.io.ByteArrayOutputStream())
+ val threads = outputs.zipWithIndex.map { case (output, i) =>
+ val thread = new Thread(() => {
+ try {
+ // Each capture is installed inside its reporting thread, and
all captures overlap.
+ Console.withOut(output) {
+ DispatchFailureReporting.report(eventStream,
+ Error(Error.NoCause, "test", getClass, "concurrent failure"))
+ }
+ } catch {
+ case _: InterruptedException =>
Thread.currentThread().interrupt()
+ } finally done.countDown()
+ }, s"dispatch-failure-reporter-$i")
+ thread.setDaemon(true)
+ thread
+ }
+
+ try {
+ threads.foreach(_.start())
+ // Keep every publish in flight until all threads have entered the
subscriber. A shared
+ // flag would divert all but one report and cannot satisfy this
assertion.
+ entered.await(remainingOrDefault.toMillis, TimeUnit.MILLISECONDS)
shouldBe true
+ deliveries.get() shouldBe threadCount
+ (Console.out should be).theSameInstanceAs(originalConsoleOut)
+ (System.out should be).theSameInstanceAs(originalSystemOut)
+ } finally {
+ // Release publishers even if starting a thread or an assertion fails.
+ proceed.countDown()
+ threads.foreach(_.join(remainingOrDefault.toMillis))
+ eventStream.unsubscribe(subscriber)
+ }
+ done.getCount shouldBe 0L
+ threads.foreach(_.isAlive shouldBe false)
+ outputs.zip(threads).foreach { case (output, thread) =>
+ val lines = output.toString("UTF-8").linesIterator.toVector
+ lines should have size 1
+ lines.head should endWith(s"nested failure on ${thread.getName}")
+ }
+ }
+ }
+}
diff --git a/actor/src/main/scala/org/apache/pekko/actor/dungeon/Dispatch.scala
b/actor/src/main/scala/org/apache/pekko/actor/dungeon/Dispatch.scala
index 03ab027db6..3a68cd52a4 100644
--- a/actor/src/main/scala/org/apache/pekko/actor/dungeon/Dispatch.scala
+++ b/actor/src/main/scala/org/apache/pekko/actor/dungeon/Dispatch.scala
@@ -22,7 +22,7 @@ import org.apache.pekko
import pekko.PekkoException
import pekko.actor._
import pekko.annotation.InternalApi
-import pekko.dispatch.{ Envelope, Mailbox }
+import pekko.dispatch.{ DispatchFailureReporting, Envelope, Mailbox }
import pekko.dispatch.MailboxType
import pekko.dispatch.ProducesMessageQueue
import pekko.dispatch.UnboundedMailbox
@@ -135,14 +135,16 @@ private[pekko] trait Dispatch { this: ActorCell =>
private def handleException: Catcher[Unit] = {
case e: InterruptedException =>
- system.eventStream.publish(Error(e, self.path.toString, clazz(actor),
"interrupted during message send"))
+ DispatchFailureReporting.report(
+ system.eventStream,
+ Error(e, self.path.toString, clazz(actor), "interrupted during message
send"))
Thread.currentThread().interrupt()
case NonFatal(e) =>
val message = e match {
case n: NoStackTrace => "swallowing exception during message send: " +
n.getMessage
case _ => "swallowing exception during message send" //
stack trace includes message
}
- system.eventStream.publish(Error(e, self.path.toString, clazz(actor),
message))
+ DispatchFailureReporting.report(system.eventStream, Error(e,
self.path.toString, clazz(actor), message))
}
// ➡➡➡ NEVER SEND THE SAME SYSTEM MESSAGE OBJECT TO TWO ACTORS ⬅⬅⬅
diff --git
a/actor/src/main/scala/org/apache/pekko/dispatch/AbstractDispatcher.scala
b/actor/src/main/scala/org/apache/pekko/dispatch/AbstractDispatcher.scala
index 08a4b16767..c41a42ebe7 100644
--- a/actor/src/main/scala/org/apache/pekko/dispatch/AbstractDispatcher.scala
+++ b/actor/src/main/scala/org/apache/pekko/dispatch/AbstractDispatcher.scala
@@ -27,7 +27,7 @@ import pekko.annotation.{ InternalApi, InternalStableApi }
import pekko.dispatch.affinity.AffinityPoolConfigurator
import pekko.dispatch.sysmsg._
import pekko.event.EventStream
-import pekko.event.Logging.{ emptyMDC, Debug, Error, LogEventException,
Warning }
+import pekko.event.Logging.{ emptyMDC, Debug, Error, LogEvent,
LogEventException, StandardOutLogger, Warning }
import pekko.util.Index
import com.typesafe.config.Config
@@ -58,7 +58,8 @@ final case class TaskInvocation(eventStream: EventStream,
runnable: Runnable, cl
def run(): Unit =
try runnable.run()
catch {
- case NonFatal(e) => eventStream.publish(Error(e, "TaskInvocation",
this.getClass, e.getMessage))
+ case NonFatal(e) =>
+ DispatchFailureReporting.report(eventStream, Error(e,
"TaskInvocation", this.getClass, e.getMessage))
} finally cleanup()
}
@@ -69,6 +70,37 @@ private[pekko] trait LoadMetrics { self: Executor =>
def atFullThrottle(): Boolean
}
+/**
+ * INTERNAL API
+ *
+ * Reporting a dispatch failure means publishing to the EventStream, which
delivers to a logging
+ * actor with `!` and therefore goes back through dispatch. When the executor
is rejecting - during
+ * shutdown, or behind a saturated queue - that re-enters the failure path and
recurses until the
+ * stack overflows, which `pekko.jvm-exit-on-fatal-error` turns into a JVM
kill.
+ *
+ * Reporting is guarded per thread: the outermost failure publishes to the
EventStream as before,
+ * and a failure reached while that publish is still in flight goes to the
stdout logger, which has
+ * no mailbox and so cannot re-enter dispatch. See
https://github.com/apache/pekko/issues/3613.
+ *
+ * The guard is a concurrent set of threads rather than a ThreadLocal. A
virtualized dispatcher runs
+ * every task on a new virtual thread, and `ThreadLocal.get` allocates a
`ThreadLocalMap` on a thread
+ * that does not have one yet - which is why `Dispatcher.batchable` turns
batching off when
+ * virtualized. Entries live only for the duration of one publish and are
removed in `finally`.
+ */
+@InternalApi
+private[pekko] object DispatchFailureReporting {
+ private val reporting = ConcurrentHashMap.newKeySet[Thread]()
+
+ def report(eventStream: EventStream, event: LogEvent): Unit = {
+ val thread = Thread.currentThread()
+ if (!reporting.add(thread)) StandardOutLogger ! event
+ else {
+ try eventStream.publish(event)
+ finally reporting.remove(thread)
+ }
+ }
+}
+
/**
* INTERNAL API
*/
@@ -186,8 +218,9 @@ abstract class MessageDispatcher(val configurator:
MessageDispatcherConfigurator
}
override def reportFailure(t: Throwable): Unit = t match {
- case e: LogEventException => eventStream.publish(e.event)
- case _ => eventStream.publish(Error(t,
getClass.getName, getClass, t.getMessage))
+ case e: LogEventException => DispatchFailureReporting.report(eventStream,
e.event)
+ case _ =>
+ DispatchFailureReporting.report(eventStream, Error(t, getClass.getName,
getClass, t.getMessage))
}
private final def ifSensibleToDoSoThenScheduleShutdown(): Unit = {
diff --git a/actor/src/main/scala/org/apache/pekko/dispatch/Dispatcher.scala
b/actor/src/main/scala/org/apache/pekko/dispatch/Dispatcher.scala
index d53ece09c3..fe92c6bfb9 100644
--- a/actor/src/main/scala/org/apache/pekko/dispatch/Dispatcher.scala
+++ b/actor/src/main/scala/org/apache/pekko/dispatch/Dispatcher.scala
@@ -109,7 +109,9 @@ class Dispatcher(
executorService.execute(invocation)
} catch {
case e2: RejectedExecutionException =>
- eventStream.publish(Error(e, getClass.getName, getClass,
"executeTask was rejected twice!"))
+ DispatchFailureReporting.report(
+ eventStream,
+ Error(e, getClass.getName, getClass, "executeTask was rejected
twice!"))
throw e2
}
}
@@ -158,7 +160,9 @@ class Dispatcher(
} catch { // Retry once
case e: RejectedExecutionException =>
mbox.setAsIdle()
- eventStream.publish(Error(e, getClass.getName, getClass,
"registerForExecution was rejected twice!"))
+ DispatchFailureReporting.report(
+ eventStream,
+ Error(e, getClass.getName, getClass, "registerForExecution
was rejected twice!"))
throw e
}
}
diff --git a/docs/src/main/paradox/logging.md b/docs/src/main/paradox/logging.md
index 24ede3c807..fef1144d0b 100644
--- a/docs/src/main/paradox/logging.md
+++ b/docs/src/main/paradox/logging.md
@@ -284,8 +284,9 @@ pekko {
}
```
-The `stdout-loglevel` is only in effect during system startup and shutdown,
and setting
-it to `OFF` as well, ensures that nothing gets logged during system startup or
shutdown.
+The `stdout-loglevel` is only in effect during system startup and shutdown.
+Setting it to `OFF` disables normal logging during these phases. Recursive
+dispatcher failures can still use the emergency stdout fallback described
below.
## Loggers
@@ -340,9 +341,14 @@ Java
When the actor system is starting up and shutting down the configured
`loggers` are not used.
Instead log messages are printed to stdout (System.out). The default log level
for this
-stdout logger is `WARNING` and it can be silenced completely by setting
+stdout logger is `WARNING` and its normal output can be silenced by setting
`pekko.stdout-loglevel=OFF`.
+If reporting a dispatcher failure triggers another failure on the same thread,
for example because
+an executor also rejects the logging actor's task, Pekko prints the nested
failure directly to stdout.
+This prevents recursive logging from overflowing the stack. The outermost
failure still goes to the
+EventStream. This emergency fallback bypasses configured loggers and
`pekko.stdout-loglevel`.
+
## SLF4J
Pekko provides a logger for [SLF4J](https://www.slf4j.org/). This module is
available in the 'pekko-slf4j.jar'.
diff --git a/docs/src/main/paradox/typed/logging.md
b/docs/src/main/paradox/typed/logging.md
index 1bb6c36030..0fb074d692 100644
--- a/docs/src/main/paradox/typed/logging.md
+++ b/docs/src/main/paradox/typed/logging.md
@@ -292,8 +292,9 @@ pekko {
}
```
-The `stdout-loglevel` is only in effect during system startup and shutdown,
and setting
-it to `OFF` as well, ensures that nothing gets logged during system startup or
shutdown.
+The `stdout-loglevel` is only in effect during system startup and shutdown.
+Setting it to `OFF` disables normal logging during these phases. Recursive
+dispatcher failures can still use the emergency stdout fallback described
below.
See @ref:[Logger names](#logger-names) for configuration of log level in SLF4J
backend for certain
modules of Pekko.
@@ -302,9 +303,14 @@ modules of Pekko.
When the actor system is starting up and shutting down the configured
`loggers` are not used.
Instead log messages are printed to stdout (System.out). The default log level
for this
-stdout logger is `WARNING` and it can be silenced completely by setting
+stdout logger is `WARNING` and its normal output can be silenced by setting
`pekko.stdout-loglevel=OFF`.
+If reporting a dispatcher failure triggers another failure on the same thread,
for example because
+an executor also rejects the logging actor's task, Pekko prints the nested
failure directly to stdout.
+This prevents recursive logging from overflowing the stack. The outermost
failure still goes to the
+EventStream. This emergency fallback bypasses configured loggers and
`pekko.stdout-loglevel`.
+
### Logging of Dead Letters
By default messages sent to dead letters are logged at info level. Existence
of dead letters
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]