From b718023ee97fcd4332b9ac8193ca18ec3b61e3d0 Mon Sep 17 00:00:00 2001 From: david Date: Tue, 8 Sep 2026 17:45:11 +0200 Subject: [PATCH 1/3] Add thread context class loader checks to error tracker --- .../main/java/dev/faststats/ErrorHelper.java | 23 ++++-- .../main/java/dev/faststats/ErrorTracker.java | 75 ++++++++++++++++++- .../faststats/SimpleErrorTrackerService.java | 2 +- 3 files changed, 89 insertions(+), 11 deletions(-) diff --git a/core/src/main/java/dev/faststats/ErrorHelper.java b/core/src/main/java/dev/faststats/ErrorHelper.java index 8277e67a..d230a059 100644 --- a/core/src/main/java/dev/faststats/ErrorHelper.java +++ b/core/src/main/java/dev/faststats/ErrorHelper.java @@ -11,6 +11,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.regex.Pattern; @@ -154,29 +155,37 @@ private static List collapseConsecutiveDuplicates(final List lin return result; } - public static boolean isSameLoader(final ClassLoader loader, final Throwable error) { - return isSameLoader(loader, error, Collections.newSetFromMap(new IdentityHashMap<>())); + public static boolean isSameLoader(final Thread thread, final ClassLoader loader, final Throwable error) { + return isSameLoader(thread, loader, error, Collections.newSetFromMap(new IdentityHashMap<>())); } - private static boolean isSameLoader(final ClassLoader loader, @Nullable final Throwable error, final Set visited) { + private static boolean isSameLoader(final Thread thread, final ClassLoader loader, @Nullable final Throwable error, final Set visited) { if (error == null || !visited.add(error)) return false; final var stackTrace = error.getStackTrace(); if (stackTrace == null || stackTrace.length == 0) - return isSameLoader(loader, error.getCause(), visited); + return isSameLoader(thread, loader, error.getCause(), visited); final var firstNonLibraryIndex = findFirstNonLibraryFrameIndex(stackTrace); - if (firstNonLibraryIndex == -1) return isSameLoader(loader, error.getCause(), visited); + if (firstNonLibraryIndex == -1) return isSameLoader(thread, loader, error.getCause(), visited); final var framesToCheck = Math.min(5, stackTrace.length - firstNonLibraryIndex); for (var i = 0; i < framesToCheck; i++) { final var frame = stackTrace[firstNonLibraryIndex + i]; if (isLibraryFrame(frame.getClassName())) continue; - if (!isFromLoader(frame, loader)) return isSameLoader(loader, error.getCause(), visited); + if (!isFromLoader(frame, loader)) return isSameLoader(thread, loader, error.getCause(), visited); } - return true; + return classLoadersMatch(thread.getContextClassLoader(), loader); + } + + private static boolean classLoadersMatch(@Nullable final ClassLoader first, @Nullable final ClassLoader second) { + if (Objects.equals(first, second)) return true; + if (first == null || second == null) return false; + if (classLoadersMatch(first.getParent(), second)) return true; + if (classLoadersMatch(first, second.getParent())) return true; + return false; } private static int findFirstNonLibraryFrameIndex(final StackTraceElement[] stackTrace) { diff --git a/core/src/main/java/dev/faststats/ErrorTracker.java b/core/src/main/java/dev/faststats/ErrorTracker.java index 687b1c7b..44578274 100644 --- a/core/src/main/java/dev/faststats/ErrorTracker.java +++ b/core/src/main/java/dev/faststats/ErrorTracker.java @@ -6,6 +6,7 @@ import java.util.Optional; import java.util.function.BiConsumer; +import java.util.function.BiPredicate; import java.util.regex.Pattern; /** @@ -15,14 +16,16 @@ */ public sealed interface ErrorTracker permits SimpleErrorTracker { /** - * Creates a context-aware error tracker policy. + * Creates a context-aware error tracker policy for the current class loader. * * @return the error tracker policy + * @see #contextAware(BiPredicate) + * @see #contextAware(ClassLoader) * @since 0.24.0 */ @Contract(value = " -> new", pure = true) static ErrorTracker contextAware() { - return contextAware(ErrorTracker.class.getClassLoader()); + return contextAware(Thread.currentThread().getContextClassLoader()); } /** @@ -34,6 +37,7 @@ static ErrorTracker contextAware() { * @param classLoader the class loader whose errors should be tracked, or {@code null} to track all errors * @return the error tracker policy * @throws IllegalStateException if the error context is already attached + * @see #contextAware(ClassLoader, BiPredicate) * @see #attachErrorContext(ClassLoader) * @since 0.24.0 */ @@ -44,6 +48,57 @@ static ErrorTracker contextAware(@Nullable final ClassLoader classLoader) { return tracker; } + /** + * Creates a context-aware error tracker policy for the current class loader with a predicate to conditionally exclude errors. + *

+ * Example use of the predicate: + *

{@code
+     * contextAware((thread, throwable) -> {
+     *   for (final var element : throwable.getStackTrace()) {
+     *     if (element.getClassName().startsWith("your.package.name")) {
+     *       return true;
+     *     }
+     *   }
+     *   return false;
+     * });}
+ * + * @param override the error inclusion override predicate + * @return the error tracker policy + * @see #contextAware(ClassLoader, BiPredicate) + * @since 0.24.0 + */ + @Contract(value = "_ -> new", pure = true) + static ErrorTracker contextAware(final BiPredicate override) { + return contextAware(); + } + + /** + * Creates a context-aware error tracker policy for the given class loader with a predicate to conditionally exclude errors. + *

+ * The returned tracker has its error context attached immediately. If the class + * loader is {@code null}, the tracker will track all errors. + *

+ * Example use of the predicate: + *

{@code
+     * contextAware(classLoader, (thread, throwable) -> {
+     *   for (final var element : throwable.getStackTrace()) {
+     *     if (element.getClassName().startsWith("your.package.name")) {
+     *       return true;
+     *     }
+     *   }
+     *   return false;
+     * });}
+ * + * @param override the error inclusion override predicate + * @return the error tracker policy + * @see #attachErrorContext(ClassLoader) + * @since 0.24.0 + */ + @Contract(value = "_, _ -> new", pure = true) + static ErrorTracker contextAware(@Nullable final ClassLoader classLoader, final BiPredicate override) { + return contextAware(); + } + /** * Creates a context-unaware error tracker policy. * @@ -256,6 +311,20 @@ default ErrorTracker anonymize(@RegExp final String pattern, final String replac */ @Contract(pure = true) static boolean isSameLoader(final ClassLoader loader, final Throwable error) { - return ErrorHelper.isSameLoader(loader, error); + return ErrorHelper.isSameLoader(Thread.currentThread(), loader, error); + } + + /** + * Checks if the error occurred in the same class loader as the provided loader and thread. + * + * @param thread the thread + * @param loader the class loader + * @param error the error + * @return whether the error occurred in the same class loader + * @since 0.23.0 + */ + @Contract(pure = true) + static boolean isSameLoader(final Thread thread, final ClassLoader loader, final Throwable error) { + return ErrorHelper.isSameLoader(thread, loader, error); } } diff --git a/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java b/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java index 250ecfce..358b5cb9 100644 --- a/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java +++ b/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java @@ -58,7 +58,7 @@ private static void handleUncaughtException(final Thread thread, final Throwable for (final var tracker : DISPATCHER_TRACKERS) { try { final var loader = tracker.attachedLoader(); - if (loader != null && !ErrorHelper.isSameLoader(loader, error)) continue; + if (loader != null && !ErrorHelper.isSameLoader(thread, loader, error)) continue; tracker.trackError(error).handled(false); tracker.getContextErrorHandler().ifPresent(handler -> handler.accept(loader, error)); } catch (final Throwable t) { From f0ce5ad30b3e3680e27916ed6f5d43fb5f86889e Mon Sep 17 00:00:00 2001 From: david Date: Thu, 1 Oct 2026 19:47:31 +0200 Subject: [PATCH 2/3] Replace virtual constructor with chainable addExclusionRule --- .../main/java/dev/faststats/ErrorHelper.java | 28 +++-- .../main/java/dev/faststats/ErrorTracker.java | 104 +++++++--------- .../dev/faststats/SimpleErrorTracker.java | 15 +++ .../faststats/SimpleErrorTrackerService.java | 1 + .../java/dev/faststats/ErrorTrackerTest.java | 112 ++++++++++++++++++ 5 files changed, 188 insertions(+), 72 deletions(-) diff --git a/core/src/main/java/dev/faststats/ErrorHelper.java b/core/src/main/java/dev/faststats/ErrorHelper.java index d230a059..8a7be16a 100644 --- a/core/src/main/java/dev/faststats/ErrorHelper.java +++ b/core/src/main/java/dev/faststats/ErrorHelper.java @@ -11,7 +11,6 @@ import java.util.List; import java.util.Locale; import java.util.Map; -import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.regex.Pattern; @@ -155,11 +154,23 @@ private static List collapseConsecutiveDuplicates(final List lin return result; } - public static boolean isSameLoader(final Thread thread, final ClassLoader loader, final Throwable error) { + private static final StackWalker STACK_WALKER = StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE); + + public static @Nullable ClassLoader callerClassLoader() { + return STACK_WALKER.walk(frames -> frames + .map(StackWalker.StackFrame::getDeclaringClass) + .filter(type -> type != ErrorHelper.class && type != ErrorTracker.class) + .filter(type -> type.getClassLoader() != null) + .findFirst()) + .orElse(ErrorTracker.class) + .getClassLoader(); + } + + public static boolean isSameLoader(@Nullable final Thread thread, final ClassLoader loader, final Throwable error) { return isSameLoader(thread, loader, error, Collections.newSetFromMap(new IdentityHashMap<>())); } - private static boolean isSameLoader(final Thread thread, final ClassLoader loader, @Nullable final Throwable error, final Set visited) { + private static boolean isSameLoader(@Nullable final Thread thread, final ClassLoader loader, @Nullable final Throwable error, final Set visited) { if (error == null || !visited.add(error)) return false; final var stackTrace = error.getStackTrace(); @@ -177,15 +188,12 @@ private static boolean isSameLoader(final Thread thread, final ClassLoader loade if (!isFromLoader(frame, loader)) return isSameLoader(thread, loader, error.getCause(), visited); } - return classLoadersMatch(thread.getContextClassLoader(), loader); + return thread == null || isRelatedContextLoader(thread.getContextClassLoader(), loader); } - private static boolean classLoadersMatch(@Nullable final ClassLoader first, @Nullable final ClassLoader second) { - if (Objects.equals(first, second)) return true; - if (first == null || second == null) return false; - if (classLoadersMatch(first.getParent(), second)) return true; - if (classLoadersMatch(first, second.getParent())) return true; - return false; + private static boolean isRelatedContextLoader(@Nullable final ClassLoader contextLoader, final ClassLoader loader) { + if (contextLoader == null) return true; + return isSameClassLoader(contextLoader, loader) || isSameClassLoader(loader, contextLoader); } private static int findFirstNonLibraryFrameIndex(final StackTraceElement[] stackTrace) { diff --git a/core/src/main/java/dev/faststats/ErrorTracker.java b/core/src/main/java/dev/faststats/ErrorTracker.java index 44578274..2242ee61 100644 --- a/core/src/main/java/dev/faststats/ErrorTracker.java +++ b/core/src/main/java/dev/faststats/ErrorTracker.java @@ -16,16 +16,15 @@ */ public sealed interface ErrorTracker permits SimpleErrorTracker { /** - * Creates a context-aware error tracker policy for the current class loader. + * Creates a context-aware error tracker policy for the class loader of the calling class. * * @return the error tracker policy - * @see #contextAware(BiPredicate) * @see #contextAware(ClassLoader) * @since 0.24.0 */ - @Contract(value = " -> new", pure = true) + @Contract(value = " -> new") static ErrorTracker contextAware() { - return contextAware(Thread.currentThread().getContextClassLoader()); + return contextAware(ErrorHelper.callerClassLoader()); } /** @@ -37,68 +36,16 @@ static ErrorTracker contextAware() { * @param classLoader the class loader whose errors should be tracked, or {@code null} to track all errors * @return the error tracker policy * @throws IllegalStateException if the error context is already attached - * @see #contextAware(ClassLoader, BiPredicate) * @see #attachErrorContext(ClassLoader) * @since 0.24.0 */ - @Contract(value = "_ -> new", pure = true) + @Contract(value = "_ -> new") static ErrorTracker contextAware(@Nullable final ClassLoader classLoader) { final var tracker = new SimpleErrorTracker(); tracker.attachErrorContext(classLoader); return tracker; } - /** - * Creates a context-aware error tracker policy for the current class loader with a predicate to conditionally exclude errors. - *

- * Example use of the predicate: - *

{@code
-     * contextAware((thread, throwable) -> {
-     *   for (final var element : throwable.getStackTrace()) {
-     *     if (element.getClassName().startsWith("your.package.name")) {
-     *       return true;
-     *     }
-     *   }
-     *   return false;
-     * });}
- * - * @param override the error inclusion override predicate - * @return the error tracker policy - * @see #contextAware(ClassLoader, BiPredicate) - * @since 0.24.0 - */ - @Contract(value = "_ -> new", pure = true) - static ErrorTracker contextAware(final BiPredicate override) { - return contextAware(); - } - - /** - * Creates a context-aware error tracker policy for the given class loader with a predicate to conditionally exclude errors. - *

- * The returned tracker has its error context attached immediately. If the class - * loader is {@code null}, the tracker will track all errors. - *

- * Example use of the predicate: - *

{@code
-     * contextAware(classLoader, (thread, throwable) -> {
-     *   for (final var element : throwable.getStackTrace()) {
-     *     if (element.getClassName().startsWith("your.package.name")) {
-     *       return true;
-     *     }
-     *   }
-     *   return false;
-     * });}
- * - * @param override the error inclusion override predicate - * @return the error tracker policy - * @see #attachErrorContext(ClassLoader) - * @since 0.24.0 - */ - @Contract(value = "_, _ -> new", pure = true) - static ErrorTracker contextAware(@Nullable final ClassLoader classLoader, final BiPredicate override) { - return contextAware(); - } - /** * Creates a context-unaware error tracker policy. * @@ -219,6 +166,33 @@ default ErrorTracker ignoreError(final Class type, @RegExp return ignoreError(type, Pattern.compile(pattern)); } + /** + * Adds a rule that excludes automatically tracked errors from being reported to FastStats. + *

+ * Rules are only applied to errors captured through an attached error context, + * after the error has been attributed to the tracker's class loader. + *

+ * If any rule returns {@code true} for the thread and error, the error is not tracked. + *

{@code
+     * // Exclude all errors without a package frame from "your.package.name"
+     * tracker.addExclusionRule((thread, error) -> {
+     *   for (final var element : error.getStackTrace()) {
+     *     if (element.getClassName().startsWith("your.package.name")) {
+     *       return false; // track this error
+     *     }
+     *   }
+     *   return true; // do not track this error
+     * });
+     * }
+ * + * @param rule the rule deciding whether an error on the given thread should be excluded + * @return the error tracker + * @see #attachErrorContext(ClassLoader) + * @since 0.31.0 + */ + @Contract(value = "_ -> this", mutates = "this") + ErrorTracker addExclusionRule(BiPredicate rule); + /** * Adds an anonymization pattern that replaces matched text in error messages. *
{@code
@@ -279,6 +253,7 @@ default ErrorTracker anonymize(@RegExp final String pattern, final String replac
      * @return whether an error context is attached
      * @since 0.23.0
      */
+    @Contract(pure = true)
     boolean isContextAttached();
 
     /**
@@ -311,20 +286,25 @@ default ErrorTracker anonymize(@RegExp final String pattern, final String replac
      */
     @Contract(pure = true)
     static boolean isSameLoader(final ClassLoader loader, final Throwable error) {
-        return ErrorHelper.isSameLoader(Thread.currentThread(), loader, error);
+        return ErrorHelper.isSameLoader(null, loader, error);
     }
 
     /**
-     * Checks if the error occurred in the same class loader as the provided loader and thread.
+     * Checks if the error occurred in the same class loader as the provided loader,
+     * taking the context class loader of the given thread into account.
+     * 

+ * In addition to the stack frame check of {@link #isSameLoader(ClassLoader, Throwable)}, the + * thread's context class loader must be either {@code null}, or be the given loader, one of + * its parents or one of its children. * - * @param thread the thread + * @param thread the thread the error occurred on * @param loader the class loader * @param error the error * @return whether the error occurred in the same class loader - * @since 0.23.0 + * @since 0.31.0 */ @Contract(pure = true) static boolean isSameLoader(final Thread thread, final ClassLoader loader, final Throwable error) { return ErrorHelper.isSameLoader(thread, loader, error); } -} +} diff --git a/core/src/main/java/dev/faststats/SimpleErrorTracker.java b/core/src/main/java/dev/faststats/SimpleErrorTracker.java index 4872366a..47cf1d31 100644 --- a/core/src/main/java/dev/faststats/SimpleErrorTracker.java +++ b/core/src/main/java/dev/faststats/SimpleErrorTracker.java @@ -14,6 +14,7 @@ import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CopyOnWriteArraySet; import java.util.function.BiConsumer; +import java.util.function.BiPredicate; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -25,6 +26,7 @@ final class SimpleErrorTracker implements ErrorTracker { private final Set> ignoredTypes = new CopyOnWriteArraySet<>(); private final Set ignoredPatterns = new CopyOnWriteArraySet<>(); private final List> anonymizationEntries = new CopyOnWriteArrayList<>(); + private final List> contextExclusionRules = new CopyOnWriteArrayList<>(); private volatile @Nullable BiConsumer<@Nullable ClassLoader, Throwable> errorEvent; private volatile @Nullable ClassLoader attachedLoader; @@ -86,6 +88,12 @@ public ErrorTracker ignoreError(final Class type, final Pat return this; } + @Override + public ErrorTracker addExclusionRule(final BiPredicate rule) { + contextExclusionRules.add(rule); + return this; + } + @Override public ErrorTracker anonymize(final Pattern pattern, final String replacement) { anonymizationEntries.add(Map.entry(pattern, replacement)); @@ -146,4 +154,11 @@ public synchronized void setContextErrorHandler(@Nullable final BiConsumer<@Null @Nullable ClassLoader attachedLoader() { return attachedLoader; } + + boolean isContextErrorIgnored(final Thread thread, final Throwable error) { + for (final var rule : contextExclusionRules) { + if (rule.test(thread, error)) return true; + } + return false; + } } diff --git a/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java b/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java index 358b5cb9..2ad89d8e 100644 --- a/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java +++ b/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java @@ -59,6 +59,7 @@ private static void handleUncaughtException(final Thread thread, final Throwable try { final var loader = tracker.attachedLoader(); if (loader != null && !ErrorHelper.isSameLoader(thread, loader, error)) continue; + if (tracker.isContextErrorIgnored(thread, error)) continue; tracker.trackError(error).handled(false); tracker.getContextErrorHandler().ifPresent(handler -> handler.accept(loader, error)); } catch (final Throwable t) { diff --git a/core/src/test/java/dev/faststats/ErrorTrackerTest.java b/core/src/test/java/dev/faststats/ErrorTrackerTest.java index 1b841aa7..dead5ef2 100644 --- a/core/src/test/java/dev/faststats/ErrorTrackerTest.java +++ b/core/src/test/java/dev/faststats/ErrorTrackerTest.java @@ -1,11 +1,15 @@ package dev.faststats; +import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; +import java.io.IOException; import java.net.URL; import java.net.URLClassLoader; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -345,6 +349,114 @@ public void attachedContextTracksUnhandledThreadError() throws InterruptedExcept } } + @Test + public void threadContextLoaderOnSameChainMatches() { + final var loader = getClass().getClassLoader(); + final var childLoader = new URLClassLoader(new URL[0], loader); + final var error = new RuntimeException("test"); + final var thread = new Thread(() -> { + }); + + thread.setContextClassLoader(loader); + assertTrue(ErrorTracker.isSameLoader(thread, loader, error)); + + thread.setContextClassLoader(loader.getParent()); + assertTrue(ErrorTracker.isSameLoader(thread, loader, error)); + + thread.setContextClassLoader(childLoader); + assertTrue(ErrorTracker.isSameLoader(thread, loader, error)); + + thread.setContextClassLoader(null); + assertTrue(ErrorTracker.isSameLoader(thread, loader, error)); + } + + @Test + public void unrelatedThreadContextLoaderDoesNotMatch() { + final var loader = getClass().getClassLoader(); + final var error = new RuntimeException("test"); + final var thread = new Thread(() -> { + }); + + thread.setContextClassLoader(new URLClassLoader(new URL[0], null)); + assertFalse(ErrorTracker.isSameLoader(thread, loader, error)); + assertTrue(ErrorTracker.isSameLoader(loader, error)); + } + + @Test + public void contextExclusionRulesExcludeErrorsFromTrackedLoader() throws InterruptedException { + final var loader = getClass().getClassLoader(); + final var tracked = createErrorWithStackFrom(ErrorTrackerTest.class.getName()); + final var excluded = createErrorWithStackFrom(MockContext.class.getName()); + final var foreign = createErrorWithStackFrom("example.Foreign"); + + final var excludedThread = new AtomicReference<@Nullable Thread>(); + final var tracker = (SimpleErrorTracker) ErrorTracker.contextAware(loader) + .addExclusionRule((thread, error) -> error.getStackTrace()[0].getClassName().equals(MockContext.class.getName())) + .addExclusionRule((thread, error) -> thread == excludedThread.get()); + try { + for (final var error : new RuntimeException[]{tracked, excluded, foreign}) { + final var thread = new Thread(() -> { + throw error; + }); + thread.start(); + thread.join(1000); + } + + final var trackedOnExcludedThread = createErrorWithStackFrom(ErrorTrackerTest.class.getName()); + final var thread = new Thread(() -> { + throw trackedOnExcludedThread; + }); + excludedThread.set(thread); + thread.start(); + thread.join(1000); + + final var reports = tracker.getFullData(); + assertEquals(1, reports.size()); + final var stack = reports.get(0).getAsJsonObject().getAsJsonArray("stack"); + assertEquals(" at " + ErrorTrackerTest.class.getName() + ".test(Test.java:1)", stack.get(1).getAsString()); + } finally { + tracker.detachErrorContext(); + } + } + + @Test + public void contextAwareResolvesCallerClassLoader() throws Exception { + final var callerName = ContextAwareCaller.class.getName(); + final var parent = getClass().getClassLoader(); + final var callerLoader = new ClassLoader(parent) { + @Override + protected Class loadClass(final String name, final boolean resolve) throws ClassNotFoundException { + if (!name.equals(callerName)) return super.loadClass(name, resolve); + synchronized (getClassLoadingLock(name)) { + final var loaded = findLoadedClass(name); + if (loaded != null) return loaded; + try (final var stream = parent.getResourceAsStream(name.replace('.', '/') + ".class")) { + final var bytes = stream.readAllBytes(); + return defineClass(name, bytes, 0, bytes.length); + } catch (final IOException e) { + throw new ClassNotFoundException(name, e); + } + } + } + }; + + @SuppressWarnings("unchecked") final var caller = (Supplier) callerLoader + .loadClass(callerName).getDeclaredConstructor().newInstance(); + final var tracker = (SimpleErrorTracker) caller.get(); + try { + assertEquals(callerLoader, tracker.attachedLoader()); + } finally { + tracker.detachErrorContext(); + } + } + + public static final class ContextAwareCaller implements Supplier { + @Override + public ErrorTracker get() { + return ErrorTracker.contextAware(); + } + } + @Test public void trackedErrorSerializesProperties() { final var tracker = (SimpleErrorTracker) ErrorTracker.contextUnaware(); From e76398af738e7cecbe301e8318ccfc4784eee784 Mon Sep 17 00:00:00 2001 From: david Date: Tue, 13 Oct 2026 16:34:10 +0200 Subject: [PATCH 3/3] treat system and platform class loaders like null --- .../main/java/dev/faststats/ErrorHelper.java | 2 + .../java/dev/faststats/ErrorTrackerTest.java | 52 ++++++++++++++----- 2 files changed, 41 insertions(+), 13 deletions(-) diff --git a/core/src/main/java/dev/faststats/ErrorHelper.java b/core/src/main/java/dev/faststats/ErrorHelper.java index 8a7be16a..18685d89 100644 --- a/core/src/main/java/dev/faststats/ErrorHelper.java +++ b/core/src/main/java/dev/faststats/ErrorHelper.java @@ -193,6 +193,8 @@ private static boolean isSameLoader(@Nullable final Thread thread, final ClassLo private static boolean isRelatedContextLoader(@Nullable final ClassLoader contextLoader, final ClassLoader loader) { if (contextLoader == null) return true; + if (contextLoader == ClassLoader.getSystemClassLoader()) return true; + if (contextLoader == ClassLoader.getPlatformClassLoader()) return true; return isSameClassLoader(contextLoader, loader) || isSameClassLoader(loader, contextLoader); } diff --git a/core/src/test/java/dev/faststats/ErrorTrackerTest.java b/core/src/test/java/dev/faststats/ErrorTrackerTest.java index dead5ef2..2ae8b1f6 100644 --- a/core/src/test/java/dev/faststats/ErrorTrackerTest.java +++ b/core/src/test/java/dev/faststats/ErrorTrackerTest.java @@ -382,6 +382,28 @@ public void unrelatedThreadContextLoaderDoesNotMatch() { assertTrue(ErrorTracker.isSameLoader(loader, error)); } + @Test + public void defaultThreadContextLoadersMatch() throws ClassNotFoundException { + final var className = PlatformLoaded.class.getName(); + final var loader = definingLoader(ClassLoader.getPlatformClassLoader(), className); + loader.loadClass(className); + final var error = createErrorWithStackFrom(className); + final var thread = new Thread(() -> { + }); + + thread.setContextClassLoader(ClassLoader.getSystemClassLoader()); + assertTrue(ErrorTracker.isSameLoader(thread, loader, error)); + + thread.setContextClassLoader(ClassLoader.getPlatformClassLoader()); + assertTrue(ErrorTracker.isSameLoader(thread, loader, error)); + + thread.setContextClassLoader(new URLClassLoader(new URL[0], ClassLoader.getSystemClassLoader())); + assertFalse(ErrorTracker.isSameLoader(thread, loader, error)); + } + + public static final class PlatformLoaded { + } + @Test public void contextExclusionRulesExcludeErrorsFromTrackedLoader() throws InterruptedException { final var loader = getClass().getClassLoader(); @@ -422,15 +444,28 @@ public void contextExclusionRulesExcludeErrorsFromTrackedLoader() throws Interru @Test public void contextAwareResolvesCallerClassLoader() throws Exception { final var callerName = ContextAwareCaller.class.getName(); - final var parent = getClass().getClassLoader(); - final var callerLoader = new ClassLoader(parent) { + final var callerLoader = definingLoader(getClass().getClassLoader(), callerName); + + @SuppressWarnings("unchecked") final var caller = (Supplier) callerLoader + .loadClass(callerName).getDeclaredConstructor().newInstance(); + final var tracker = (SimpleErrorTracker) caller.get(); + try { + assertEquals(callerLoader, tracker.attachedLoader()); + } finally { + tracker.detachErrorContext(); + } + } + + private ClassLoader definingLoader(final ClassLoader parent, final String className) { + final var source = getClass().getClassLoader(); + return new ClassLoader(parent) { @Override protected Class loadClass(final String name, final boolean resolve) throws ClassNotFoundException { - if (!name.equals(callerName)) return super.loadClass(name, resolve); + if (!name.equals(className)) return super.loadClass(name, resolve); synchronized (getClassLoadingLock(name)) { final var loaded = findLoadedClass(name); if (loaded != null) return loaded; - try (final var stream = parent.getResourceAsStream(name.replace('.', '/') + ".class")) { + try (final var stream = source.getResourceAsStream(name.replace('.', '/') + ".class")) { final var bytes = stream.readAllBytes(); return defineClass(name, bytes, 0, bytes.length); } catch (final IOException e) { @@ -439,15 +474,6 @@ protected Class loadClass(final String name, final boolean resolve) throws Cl } } }; - - @SuppressWarnings("unchecked") final var caller = (Supplier) callerLoader - .loadClass(callerName).getDeclaredConstructor().newInstance(); - final var tracker = (SimpleErrorTracker) caller.get(); - try { - assertEquals(callerLoader, tracker.attachedLoader()); - } finally { - tracker.detachErrorContext(); - } } public static final class ContextAwareCaller implements Supplier {