This is an automated email from the ASF dual-hosted git repository.

olamy pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/maven-surefire.git


The following commit(s) were added to refs/heads/master by this push:
     new 90529e9fa Use StackWalker in StackTraceProvider when available (Java 
9+) (#3374)
90529e9fa is described below

commit 90529e9fab1a2df720d9a88c1abb6c34a4ab5336
Author: Olivier Lamy <[email protected]>
AuthorDate: Wed Jul 1 03:48:51 2026 +1000

    Use StackWalker in StackTraceProvider when available (Java 9+) (#3374)
    
    Signed-off-by: Olivier Lamy <[email protected]>
---
 .../src/site/markdown/architecture.md              |   1 +
 .../surefire/api/report/StackTraceProvider.java    |  33 ++++-
 .../surefire/api/report/StackWalkerStrategy.java   | 140 ++++++++++++++++++++
 .../api/report/StackTraceProviderTest.java         | 145 +++++++++++++++++++++
 4 files changed, 318 insertions(+), 1 deletion(-)

diff --git a/maven-surefire-plugin/src/site/markdown/architecture.md 
b/maven-surefire-plugin/src/site/markdown/architecture.md
index 3769e9e7a..e1edaf09a 100644
--- a/maven-surefire-plugin/src/site/markdown/architecture.md
+++ b/maven-surefire-plugin/src/site/markdown/architecture.md
@@ -509,6 +509,7 @@ The new `StackTraceProvider` class (in `surefire-api`) 
introduces:
 - **Frame limit**: Maximum 15 frames per stack trace (sufficient to capture 
the test class after surefire framework frames)
 - **Package filtering**: JDK packages (`java.`, `javax.`, `sun.`, `jdk.`) 
filtered by default
 - **Configurable prefixes**: Users can specify custom filter prefixes that 
**replace** (not add to) the defaults
+- **Lazy capture on Java 9+**: When running on Java 9 or later, capture uses 
the JDK `java.lang.StackWalker` API (accessed via reflection in 
`StackWalkerStrategy`, so the code still compiles and runs on Java 8). Combined 
with the frame limit, `StackWalker` walks the stack lazily and stops once the 
limit is reached instead of materializing the whole stack, and its default 
options skip reflection and synthetic (lambda) frames. On Java 8, or if the 
`StackWalker` call fails, it transparently [...]
 
 ### Memory impact
 
diff --git 
a/surefire-api/src/main/java/org/apache/maven/surefire/api/report/StackTraceProvider.java
 
b/surefire-api/src/main/java/org/apache/maven/surefire/api/report/StackTraceProvider.java
index feb551a4e..32e9c39a3 100644
--- 
a/surefire-api/src/main/java/org/apache/maven/surefire/api/report/StackTraceProvider.java
+++ 
b/surefire-api/src/main/java/org/apache/maven/surefire/api/report/StackTraceProvider.java
@@ -93,15 +93,39 @@ public static void configure(String prefixes, int 
maxFrameCount) {
      * Returns the stack trace as a list of "classname#methodname" strings.
      * Filters out framework classes and limits to {@value 
#DEFAULT_MAX_FRAMES} frames by default.
      * Returns an empty list if max frames is set to 0 or negative.
+     * <p>
+     * On Java 9+ this uses the lazy {@code java.lang.StackWalker} API (via 
{@link StackWalkerStrategy}) to reduce
+     * memory usage and improve performance; on Java 8, or if the {@code 
StackWalker} call fails, it falls back to
+     * {@link Thread#getStackTrace()}.
      *
      * @return the filtered and truncated stack trace
      */
     static List<String> getStack() {
+        if (maxFrames <= 0) {
+            return Collections.emptyList();
+        }
+        if (StackWalkerStrategy.isAvailable()) {
+            List<String> stack = StackWalkerStrategy.walk(
+                    maxFrames, className -> isFrameworkClass(className) || 
isInternalClass(className));
+            if (stack != null) {
+                return stack;
+            }
+        }
+        return getStackLegacy();
+    }
+
+    /**
+     * Captures the stack using {@link Thread#getStackTrace()}. Used as a 
fallback on Java 8 or when the
+     * {@code StackWalker} call fails. Package-private for testing.
+     *
+     * @return the filtered and truncated stack trace
+     */
+    static List<String> getStackLegacy() {
         if (maxFrames <= 0) {
             return Collections.emptyList();
         }
         return Arrays.stream(Thread.currentThread().getStackTrace())
-                .filter(e -> !isFrameworkClass(e.getClassName()))
+                .filter(e -> !isFrameworkClass(e.getClassName()) && 
!isInternalClass(e.getClassName()))
                 .limit(maxFrames)
                 .map(e -> e.getClassName() + "#" + e.getMethodName())
                 .collect(Collectors.toList());
@@ -115,4 +139,11 @@ private static boolean isFrameworkClass(String className) {
         }
         return false;
     }
+
+    // Surefire's own stack-capture plumbing should never appear in the 
result; excluding it keeps both the
+    // StackWalker and the Thread.getStackTrace() paths equivalent and avoids 
wasting the frame budget.
+    private static boolean isInternalClass(String className) {
+        return className.equals(StackTraceProvider.class.getName())
+                || className.equals(StackWalkerStrategy.class.getName());
+    }
 }
diff --git 
a/surefire-api/src/main/java/org/apache/maven/surefire/api/report/StackWalkerStrategy.java
 
b/surefire-api/src/main/java/org/apache/maven/surefire/api/report/StackWalkerStrategy.java
new file mode 100644
index 000000000..b07b425f8
--- /dev/null
+++ 
b/surefire-api/src/main/java/org/apache/maven/surefire/api/report/StackWalkerStrategy.java
@@ -0,0 +1,140 @@
+/*
+ * 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.maven.surefire.api.report;
+
+import java.lang.reflect.Method;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import static org.apache.maven.surefire.api.util.ReflectionUtils.tryGetMethod;
+import static org.apache.maven.surefire.api.util.ReflectionUtils.tryLoadClass;
+
+/**
+ * Captures the current thread's stack with {@code java.lang.StackWalker}, the 
cheaper alternative to
+ * {@link Thread#getStackTrace()}.
+ * <p>
+ * {@code StackWalker} only exists on Java 9+, so we reach it through 
reflection and let the code still compile and run
+ * on Java 8 (callers fall back to {@link Thread#getStackTrace()} when it is 
not {@link #isAvailable() available}).
+ * <p>
+ * The win is that {@code StackWalker} is lazy: together with {@link 
Stream#limit(long)} it stops as soon as it has
+ * enough frames instead of building the whole stack, and by default it hides 
reflection and lambda frames. We ask for
+ * an instance with the default options (no {@code RETAIN_CLASS_REFERENCE} 
&mdash; we only want class and method names)
+ * and tell it roughly how many frames to expect so it can size its buffers up 
front.
+ *
+ * @since 3.6.0
+ */
+final class StackWalkerStrategy {
+
+    /** Whether the {@code StackWalker} API is available and reflection setup 
succeeded. */
+    private static final boolean AVAILABLE;
+
+    private static final Method GET_INSTANCE; // StackWalker.getInstance(Set, 
int) -> StackWalker
+    private static final Method WALK; // StackWalker.walk(Function) -> Object
+    private static final Method FRAME_CLASS_NAME; // 
StackWalker.StackFrame.getClassName() -> String
+    private static final Method FRAME_METHOD_NAME; // 
StackWalker.StackFrame.getMethodName() -> String
+
+    static {
+        ClassLoader classLoader = 
Thread.currentThread().getContextClassLoader();
+
+        Class<?> stackWalkerClass = tryLoadClass(classLoader, 
"java.lang.StackWalker");
+        // The concrete frame implementation is a non-exported jdk.internal 
class, so the accessor methods must be
+        // resolved on the public StackWalker.StackFrame interface to avoid 
IllegalAccessException at invoke time.
+        Class<?> stackFrameClass = tryLoadClass(classLoader, 
"java.lang.StackWalker$StackFrame");
+
+        Method getInstance = null;
+        Method walk = null;
+        Method frameClassName = null;
+        Method frameMethodName = null;
+
+        if (stackWalkerClass != null && stackFrameClass != null) {
+            getInstance = tryGetMethod(stackWalkerClass, "getInstance", 
Set.class, int.class);
+            walk = tryGetMethod(stackWalkerClass, "walk", Function.class);
+            frameClassName = tryGetMethod(stackFrameClass, "getClassName");
+            frameMethodName = tryGetMethod(stackFrameClass, "getMethodName");
+        }
+
+        AVAILABLE = getInstance != null && walk != null && frameClassName != 
null && frameMethodName != null;
+        GET_INSTANCE = getInstance;
+        WALK = walk;
+        FRAME_CLASS_NAME = frameClassName;
+        FRAME_METHOD_NAME = frameMethodName;
+    }
+
+    private StackWalkerStrategy() {
+        throw new IllegalStateException("no instantiable constructor");
+    }
+
+    /**
+     * Returns whether the {@code StackWalker} API is available for use (Java 
9+).
+     *
+     * @return {@code true} if the {@code StackWalker} API is available
+     */
+    static boolean isAvailable() {
+        return AVAILABLE;
+    }
+
+    /**
+     * Walks the current thread's stack and returns up to {@code maxFrames} 
frames as {@code "className#methodName"}
+     * strings, excluding frames whose class name matches {@code excludeClass}.
+     *
+     * @param maxFrames    maximum number of frames to return (after exclusion)
+     * @param excludeClass predicate that returns {@code true} for class names 
to drop from the result
+     * @return the filtered, truncated stack, or {@code null} if the {@code 
StackWalker} call failed and the caller
+     *         should fall back to another mechanism
+     */
+    @SuppressWarnings("unchecked")
+    static List<String> walk(int maxFrames, Predicate<String> excludeClass) {
+        try {
+            Object stackWalker = GET_INSTANCE.invoke(null, 
Collections.emptySet(), maxFrames);
+            Function<Stream<Object>, List<String>> walkFunction = stream -> 
stream.map(StackWalkerStrategy::toFrameInfo)
+                    .filter(frame -> !excludeClass.test(frame.className))
+                    .limit(maxFrames)
+                    .map(frame -> frame.className + "#" + frame.methodName)
+                    .collect(Collectors.toList());
+            return (List<String>) WALK.invoke(stackWalker, walkFunction);
+        } catch (ReflectiveOperationException | RuntimeException e) {
+            return null;
+        }
+    }
+
+    private static FrameInfo toFrameInfo(Object frame) {
+        try {
+            String className = (String) FRAME_CLASS_NAME.invoke(frame);
+            String methodName = (String) FRAME_METHOD_NAME.invoke(frame);
+            return new FrameInfo(className, methodName);
+        } catch (ReflectiveOperationException e) {
+            throw new IllegalStateException(e);
+        }
+    }
+
+    private static final class FrameInfo {
+        private final String className;
+        private final String methodName;
+
+        private FrameInfo(String className, String methodName) {
+            this.className = className;
+            this.methodName = methodName;
+        }
+    }
+}
diff --git 
a/surefire-api/src/test/java/org/apache/maven/surefire/api/report/StackTraceProviderTest.java
 
b/surefire-api/src/test/java/org/apache/maven/surefire/api/report/StackTraceProviderTest.java
new file mode 100644
index 000000000..182b213b8
--- /dev/null
+++ 
b/surefire-api/src/test/java/org/apache/maven/surefire/api/report/StackTraceProviderTest.java
@@ -0,0 +1,145 @@
+/*
+ * 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.maven.surefire.api.report;
+
+import java.util.List;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.parallel.Isolated;
+
+import static 
org.apache.maven.surefire.api.report.StackTraceProvider.DEFAULT_MAX_FRAMES;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+/**
+ * Tests for {@link StackTraceProvider} covering both the {@code StackWalker} 
path (Java 9+) and the
+ * {@link Thread#getStackTrace()} fallback path.
+ * <p>
+ * Marked {@link Isolated} because these tests mutate {@link 
StackTraceProvider}'s global static configuration
+ * via {@code configure(...)}; running concurrently with other test classes 
would be unsafe.
+ */
+@Isolated
+class StackTraceProviderTest {
+
+    @BeforeEach
+    @AfterEach
+    void resetDefaults() {
+        StackTraceProvider.configure(null, DEFAULT_MAX_FRAMES);
+    }
+
+    @Test
+    void getStackContainsCallerAsFirstFrame() {
+        List<String> stack = StackTraceProvider.getStack();
+        assertThat(stack).isNotEmpty();
+        assertThat(stack.get(0)).isEqualTo(getClass().getName() + 
"#getStackContainsCallerAsFirstFrame");
+    }
+
+    @Test
+    void internalAndJdkFramesAreExcluded() {
+        List<String> stack = StackTraceProvider.getStack();
+        assertThat(stack)
+                .noneMatch(frame -> frame.startsWith("java.")
+                        || frame.startsWith("javax.")
+                        || frame.startsWith("sun.")
+                        || frame.startsWith("jdk."));
+        assertThat(stack).noneMatch(frame -> 
frame.startsWith(StackTraceProvider.class.getName() + "#"));
+        assertThat(stack).noneMatch(frame -> 
frame.startsWith(StackWalkerStrategy.class.getName() + "#"));
+    }
+
+    @Test
+    void zeroMaxFramesReturnsEmpty() {
+        StackTraceProvider.configure(null, 0);
+        assertThat(StackTraceProvider.getStack()).isEmpty();
+        assertThat(StackTraceProvider.getStackLegacy()).isEmpty();
+    }
+
+    @Test
+    void negativeMaxFramesReturnsEmpty() {
+        StackTraceProvider.configure(null, -5);
+        assertThat(StackTraceProvider.getStack()).isEmpty();
+        assertThat(StackTraceProvider.getStackLegacy()).isEmpty();
+    }
+
+    @Test
+    void frameLimitIsRespected() {
+        StackTraceProvider.configure(null, 3);
+        
assertThat(StackTraceProvider.getStack().size()).isLessThanOrEqualTo(3);
+        
assertThat(StackTraceProvider.getStackLegacy().size()).isLessThanOrEqualTo(3);
+    }
+
+    @Test
+    void customPrefixesReplaceDefaults() {
+        // Default configuration filters JDK frames, so the 
Thread.getStackTrace() frame is removed.
+        assertThat(StackTraceProvider.getStackLegacy()).noneMatch(frame -> 
frame.startsWith("java."));
+
+        // A custom prefix replaces the defaults, so "java." is no longer 
filtered and the leading
+        // java.lang.Thread#getStackTrace frame reappears in the legacy path.
+        StackTraceProvider.configure("com.example.nonexistent.", 
DEFAULT_MAX_FRAMES);
+        assertThat(StackTraceProvider.getStackLegacy()).anyMatch(frame -> 
frame.startsWith("java."));
+    }
+
+    @Test
+    void emptyPrefixesDisableFiltering() {
+        StackTraceProvider.configure("", DEFAULT_MAX_FRAMES);
+        assertThat(StackTraceProvider.getStackLegacy()).anyMatch(frame -> 
frame.startsWith("java."));
+    }
+
+    @Test
+    void nullPrefixesRestoreDefaults() {
+        StackTraceProvider.configure("", DEFAULT_MAX_FRAMES);
+        StackTraceProvider.configure(null, DEFAULT_MAX_FRAMES);
+        assertThat(StackTraceProvider.getStackLegacy()).noneMatch(frame -> 
frame.startsWith("java."));
+    }
+
+    @Test
+    void stackWalkerAndLegacyShareFirstFrame() {
+        String expected = getClass().getName() + 
"#stackWalkerAndLegacyShareFirstFrame";
+        assertThat(StackTraceProvider.getStack().get(0)).isEqualTo(expected);
+        
assertThat(StackTraceProvider.getStackLegacy().get(0)).isEqualTo(expected);
+    }
+
+    @Test
+    void strategyAvailabilityMatchesRuntime() {
+        boolean java9OrLater = 
!System.getProperty("java.specification.version").startsWith("1.");
+        assertThat(StackWalkerStrategy.isAvailable()).isEqualTo(java9OrLater);
+    }
+
+    @Test
+    void strategyWalkReturnsBoundedFrames() {
+        assumeTrue(StackWalkerStrategy.isAvailable());
+        List<String> frames = StackWalkerStrategy.walk(5, className -> false);
+        assertThat(frames).isNotNull().isNotEmpty();
+        assertThat(frames.size()).isLessThanOrEqualTo(5);
+        assertThat(frames).allMatch(frame -> frame.contains("#"));
+        // Nothing excluded, so the first frame is StackWalkerStrategy.walk 
itself.
+        
assertThat(frames.get(0)).isEqualTo(StackWalkerStrategy.class.getName() + 
"#walk");
+        assertThat(frames).anyMatch(frame -> frame.equals(getClass().getName() 
+ "#strategyWalkReturnsBoundedFrames"));
+    }
+
+    @Test
+    void strategyWalkAppliesExcludePredicate() {
+        assumeTrue(StackWalkerStrategy.isAvailable());
+        List<String> frames =
+                StackWalkerStrategy.walk(10, className -> 
className.equals(StackWalkerStrategy.class.getName()));
+        assertThat(frames).noneMatch(frame -> 
frame.startsWith(StackWalkerStrategy.class.getName() + "#"));
+        assertThat(frames.get(0)).isEqualTo(getClass().getName() + 
"#strategyWalkAppliesExcludePredicate");
+    }
+}

Reply via email to