diff --git a/spring-core/src/main/java/org/springframework/core/KotlinDetector.java b/spring-core/src/main/java/org/springframework/core/KotlinDetector.java index c7852e8e83bf..8f8a4d9b5136 100644 --- a/spring-core/src/main/java/org/springframework/core/KotlinDetector.java +++ b/spring-core/src/main/java/org/springframework/core/KotlinDetector.java @@ -18,6 +18,8 @@ import java.lang.annotation.Annotation; import java.lang.reflect.Method; +import java.lang.reflect.Type; +import java.util.IdentityHashMap; import org.jspecify.annotations.Nullable; @@ -141,6 +143,10 @@ public static boolean isInlineClass(Class clazz) { * @since 7.0 */ public static boolean hasSerializableAnnotation(ResolvableType type) { + return hasSerializableAnnotation(type, new IdentityHashMap<>()); + } + + private static boolean hasSerializableAnnotation(ResolvableType type, IdentityHashMap visitedTypes) { Class resolvedClass = type.resolve(); if (KOTLIN_SERIALIZABLE == null || resolvedClass == null) { return false; @@ -148,12 +154,31 @@ public static boolean hasSerializableAnnotation(ResolvableType type) { if (resolvedClass.isAnnotationPresent(KOTLIN_SERIALIZABLE)) { return true; } - for (ResolvableType genericType : type.getGenerics()) { - if (hasSerializableAnnotation(genericType)) { - return true; + + Type sourceType = type.getType(); + if (markVisited(visitedTypes, sourceType)) { + // Already visited - short circuit. + return false; + } + + try { + for (ResolvableType genericType : type.getGenerics()) { + if (hasSerializableAnnotation(genericType, visitedTypes)) { + return true; + } } + return false; + } + finally { + // `visitedTypes` are for cycle detection only, not global memoization. + visitedTypes.remove(sourceType); } - return false; + } + + @SuppressWarnings("ConstantValue") + private static boolean markVisited(IdentityHashMap visitedTypes, Type type) { + // JSpecify based nullability analysis thinks that `put` always returns a non-null value + return visitedTypes.put(type, Boolean.TRUE) != null; } } diff --git a/spring-core/src/test/kotlin/org/springframework/core/KotlinDetectorTests.kt b/spring-core/src/test/kotlin/org/springframework/core/KotlinDetectorTests.kt index a9048b7c5b88..3e96e0ae6df4 100644 --- a/spring-core/src/test/kotlin/org/springframework/core/KotlinDetectorTests.kt +++ b/spring-core/src/test/kotlin/org/springframework/core/KotlinDetectorTests.kt @@ -68,6 +68,15 @@ class KotlinDetectorTests { Assertions.assertThat(KotlinDetector.hasSerializableAnnotation(ResolvableType.NONE)).isFalse() } + @Test + fun hasSerializableAnnotationWithRecursiveGeneric() { + Assertions.assertThat(KotlinDetector.hasSerializableAnnotation( + ResolvableType.forClass(RecursiveGeneric::class.java))).isFalse() + } + + // Real life example: Guava's `ImmutableEnumSet`. + class RecursiveGeneric> + @JvmInline value class ValueClass(val value: String)