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]

Reply via email to