Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions maven-surefire-plugin/src/site/markdown/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 falls back to `Thread.getStackTrace()`.

### Memory impact

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -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());
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
Original file line number Diff line number Diff line change
@@ -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");
}
}
Loading