Fix `NPE` in `ThrowableStackTraceRenderer` due to concurrent mutation of suppressed exceptions (#3934)

Co-authored-by: Volkan Yazıcı <volkan@yazi.ci>
diff --git a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/pattern/ThrowablePatternConverterTest.java b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/pattern/ThrowablePatternConverterTest.java
index 24230dd..c893947 100644
--- a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/pattern/ThrowablePatternConverterTest.java
+++ b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/pattern/ThrowablePatternConverterTest.java
@@ -26,7 +26,13 @@
 import java.nio.charset.Charset;
 import java.nio.charset.StandardCharsets;
 import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.locks.LockSupport;
 import java.util.stream.Collectors;
+import java.util.stream.IntStream;
 import java.util.stream.Stream;
 import org.apache.logging.log4j.Level;
 import org.apache.logging.log4j.core.LogEvent;
@@ -34,6 +40,7 @@
 import org.apache.logging.log4j.core.layout.PatternLayout;
 import org.jspecify.annotations.Nullable;
 import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.RepeatedTest;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.MethodSource;
@@ -435,6 +442,66 @@
                     // We replace the `~[?:1.8.0_422]` suffix of such classes with `~[?:0]`.
                     .replaceAll(" ~\\[\\?:[^]]+](\\Q" + conversionEnding + "\\E|$)", " ~[?:0]$1");
         }
+
+        @RepeatedTest(10)
+        @Issue("https://github.com/apache/logging-log4j2/issues/3929")
+        void concurrent_suppressed_mutation_should_not_cause_failure() throws Exception {
+
+            // Test constants
+            final int threadCount = Math.max(8, Runtime.getRuntime().availableProcessors());
+            final ExecutorService executor = Executors.newFixedThreadPool(threadCount);
+            final Exception exception = new Exception();
+            final CountDownLatch startLatch =
+                    new CountDownLatch(threadCount + /* the main thread invoking the rendering: */ 1);
+            final int exceptionPerThreadCount = 100;
+            final AtomicInteger runningThreadCountRef = new AtomicInteger(threadCount);
+
+            // Schedule threads that will start adding suppressed exceptions with the start signal
+            for (int threadIndex = 0; threadIndex < threadCount; threadIndex++) {
+                final int threadIndex_ = threadIndex;
+                executor.submit(() -> {
+                    try {
+                        List<Exception> exceptions = IntStream.range(0, exceptionPerThreadCount)
+                                .mapToObj(exceptionIndex -> new Exception(threadIndex_ + "-" + exceptionIndex))
+                                .collect(Collectors.toList());
+                        startLatch.countDown();
+                        startLatch.await();
+                        for (int exceptionIndex = 0; exceptionIndex < exceptionPerThreadCount; exceptionIndex++) {
+                            exception.addSuppressed(exceptions.get(exceptionIndex));
+                            // Give some time slack to increase randomness
+                            LockSupport.parkNanos(1);
+                        }
+                    } catch (InterruptedException ignored) {
+                        // Restore the interrupt
+                        Thread.currentThread().interrupt();
+                    } finally {
+                        runningThreadCountRef.decrementAndGet();
+                    }
+                });
+            }
+
+            // Create the formatter
+            final List<PatternFormatter> patternFormatters = PATTERN_PARSER.parse(patternPrefix, false, true, true);
+            assertThat(patternFormatters).hasSize(1);
+            final PatternFormatter patternFormatter = patternFormatters.get(0);
+
+            // Create the log event and the layout buffer
+            final LogEvent logEvent = Log4jLogEvent.newBuilder()
+                    .setThrown(exception)
+                    .setLevel(LEVEL)
+                    .build();
+            final StringBuilder buffer = new StringBuilder(16384);
+
+            // Trigger the start latch and format the exception
+            startLatch.countDown();
+            startLatch.await();
+            while (runningThreadCountRef.get() > 0) {
+                // Give some time slack to increase randomness
+                LockSupport.parkNanos(1);
+                patternFormatter.format(logEvent, buffer);
+                buffer.setLength(0);
+            }
+        }
     }
 
     static String convert(final String pattern) {
diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/ThrowableInvertedStackTraceRenderer.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/ThrowableInvertedStackTraceRenderer.java
index 6ad7173..f1d2679 100644
--- a/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/ThrowableInvertedStackTraceRenderer.java
+++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/ThrowableInvertedStackTraceRenderer.java
@@ -60,6 +60,7 @@
             buffer.append(']');
             buffer.append(lineSeparator);
         } else {
+            final Context.Metadata metadata = context.metadataByThrowable.get(throwable);
             lineCapacityAcquired = renderCause(
                     buffer,
                     throwable.getCause(),
@@ -73,9 +74,8 @@
             }
             renderThrowableMessage(buffer, throwable);
             buffer.append(lineSeparator);
-            renderStackTraceElements(buffer, throwable, context, prefix, lineSeparator);
-            renderSuppressed(
-                    buffer, throwable.getSuppressed(), context, visitedThrowables, prefix + '\t', lineSeparator);
+            renderStackTraceElements(buffer, throwable, context, metadata, prefix, lineSeparator);
+            renderSuppressed(buffer, metadata.suppressed, context, visitedThrowables, prefix + '\t', lineSeparator);
         }
     }
 
diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/ThrowableStackTraceRenderer.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/ThrowableStackTraceRenderer.java
index b16e9b9..301f6ef 100644
--- a/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/ThrowableStackTraceRenderer.java
+++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/ThrowableStackTraceRenderer.java
@@ -95,11 +95,11 @@
             buffer.append(']');
             buffer.append(lineSeparator);
         } else {
+            final Context.Metadata metadata = context.metadataByThrowable.get(throwable);
             renderThrowableMessage(buffer, throwable);
             buffer.append(lineSeparator);
-            renderStackTraceElements(buffer, throwable, context, prefix, lineSeparator);
-            renderSuppressed(
-                    buffer, throwable.getSuppressed(), context, visitedThrowables, prefix + '\t', lineSeparator);
+            renderStackTraceElements(buffer, throwable, context, metadata, prefix, lineSeparator);
+            renderSuppressed(buffer, metadata.suppressed, context, visitedThrowables, prefix + '\t', lineSeparator);
             renderCause(buffer, throwable.getCause(), context, visitedThrowables, prefix, lineSeparator);
         }
     }
@@ -150,10 +150,10 @@
             final StringBuilder buffer,
             final Throwable throwable,
             final C context,
+            final Context.Metadata metadata,
             final String prefix,
             final String lineSeparator) {
         context.ignoredStackTraceElementCount = 0;
-        final Context.Metadata metadata = context.metadataByThrowable.get(throwable);
         final StackTraceElement[] stackTraceElements = throwable.getStackTrace();
         for (int i = 0; i < metadata.stackLength; i++) {
             renderStackTraceElement(buffer, stackTraceElements[i], context, prefix, lineSeparator);
@@ -268,9 +268,19 @@
              */
             final int stackLength;
 
-            private Metadata(final int commonElementCount, final int stackLength) {
+            /**
+             * The suppressed exceptions attached to this {@link Throwable}.
+             * This needs to be captured separately since {@link Throwable#getSuppressed()} can change.
+             *
+             * @see <a href="https://github.com/apache/logging-log4j2/issues/3929">#3929</a>
+             * @see <a href="https://github.com/apache/logging-log4j2/pull/3934">#3934</a>
+             */
+            final Throwable[] suppressed;
+
+            private Metadata(final int commonElementCount, final int stackLength, final Throwable[] suppressed) {
                 this.commonElementCount = commonElementCount;
                 this.stackLength = stackLength;
+                this.suppressed = suppressed;
             }
 
             static Map<Throwable, Metadata> ofThrowable(final Throwable throwable) {
@@ -288,11 +298,12 @@
                 // Populate metadata of the current throwable
                 @Nullable
                 final StackTraceElement[] rootTrace = parentThrowable == null ? null : parentThrowable.getStackTrace();
-                final Metadata metadata = populateMetadata(rootTrace, throwable.getStackTrace());
+                final Metadata metadata =
+                        populateMetadata(rootTrace, throwable.getStackTrace(), throwable.getSuppressed());
                 metadataByThrowable.put(throwable, metadata);
 
                 // Populate metadata of suppressed exceptions
-                for (final Throwable suppressed : throwable.getSuppressed()) {
+                for (final Throwable suppressed : metadata.suppressed) {
                     if (!visitedThrowables.contains(suppressed)) {
                         visitedThrowables.add(suppressed);
                         populateMetadata(metadataByThrowable, visitedThrowables, throwable, suppressed);
@@ -308,7 +319,9 @@
             }
 
             private static Metadata populateMetadata(
-                    @Nullable final StackTraceElement[] parentTrace, final StackTraceElement[] currentTrace) {
+                    @Nullable final StackTraceElement[] parentTrace,
+                    final StackTraceElement[] currentTrace,
+                    final Throwable[] suppressed) {
                 int commonElementCount;
                 int stackLength;
                 if (parentTrace != null) {
@@ -326,7 +339,7 @@
                     commonElementCount = 0;
                     stackLength = currentTrace.length;
                 }
-                return new Metadata(commonElementCount, stackLength);
+                return new Metadata(commonElementCount, stackLength, suppressed);
             }
         }
     }
diff --git a/src/changelog/2.25.1/3929_ThrowableStackTraceRenderer-npe.xml b/src/changelog/2.25.1/3929_ThrowableStackTraceRenderer-npe.xml
new file mode 100644
index 0000000..1028480
--- /dev/null
+++ b/src/changelog/2.25.1/3929_ThrowableStackTraceRenderer-npe.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<entry xmlns="https://logging.apache.org/xml/ns"
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xsi:schemaLocation="
+           https://logging.apache.org/xml/ns
+           https://logging.apache.org/xml/ns/log4j-changelog-0.xsd"
+       type="fixed">
+  <issue id="3929" link="https://github.com/apache/logging-log4j2/issues/3929"/>
+  <issue id="3934" link="https://github.com/apache/logging-log4j2/pull/3934"/>
+  <description format="asciidoc">
+    Fixes `NullPointerException` thrown by `ThrowableStackTraceRenderer` when suppressed exceptions are mutated concurrently.
+  </description>
+</entry>