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
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,10 @@ public static ClassNode lowestUpperBound(final List<ClassNode> nodes) {
* @since 2.0.0
*/
public static ClassNode lowestUpperBound(final ClassNode a, final ClassNode b) {
return lowestUpperBound(new LowestUpperBoundContext(), a, b);
}

private static ClassNode lowestUpperBound(final LowestUpperBoundContext ctx, final ClassNode a, final ClassNode b) {
ClassNode lub = lowestUpperBound(a, b, null, null);
if (lub == null || !lub.isUsingGenerics()
|| lub.isGenericsPlaceHolder()) { // GROOVY-10330
Expand All @@ -222,20 +226,20 @@ public static ClassNode lowestUpperBound(final ClassNode a, final ClassNode b) {
// plus the interfaces
ClassNode superClass = lub.getSuperClass();
if (superClass.redirect().getGenericsTypes() != null) {
superClass = parameterizeLowestUpperBound(superClass, a, b, lub);
superClass = parameterizeLowestUpperBound(ctx, superClass, a, b, lub);
}

ClassNode[] interfaces = lub.getInterfaces().clone();
for (int i = 0, n = interfaces.length; i < n; i += 1) {
ClassNode icn = interfaces[i];
if (icn.redirect().getGenericsTypes() != null) {
interfaces[i] = parameterizeLowestUpperBound(icn, a, b, lub);
interfaces[i] = parameterizeLowestUpperBound(ctx, icn, a, b, lub);
}
}

return new LowestUpperBoundClassNode(lub.getUnresolvedName(), superClass, interfaces);
} else {
return parameterizeLowestUpperBound(lub, a, b, lub);
return parameterizeLowestUpperBound(ctx, lub, a, b, lub);
}
}

Expand All @@ -246,13 +250,14 @@ public static ClassNode lowestUpperBound(final ClassNode a, final ClassNode b) {
*
* For example, if LUB is Set&lt;T&gt; and a is Set&lt;String&gt; and b is Set&lt;StringBuffer&gt;, this
* will return a LUB which parameterized type matches Set&lt;? extends CharSequence&gt;
* @param ctx tracks (t1, t2) pairs whose LUB is currently being computed, so this method can detect recursive calls (GROOVY-11770)
* @param lub the type to be parameterized
* @param a parameterized type a
* @param b parameterized type b
* @param fallback if we detect a recursive call, use this LUB as the parameterized type instead of computing a value
* @return the class node representing the parameterized lowest upper bound
*/
private static ClassNode parameterizeLowestUpperBound(final ClassNode lub, final ClassNode a, final ClassNode b, final ClassNode fallback) {
private static ClassNode parameterizeLowestUpperBound(final LowestUpperBoundContext ctx, final ClassNode lub, final ClassNode a, final ClassNode b, final ClassNode fallback) {
if (a.toString(false).equals(b.toString(false))) return lub;
// a common super type exists, all we have to do is to parameterize
// it according to the types provided by the two class nodes
Expand All @@ -273,11 +278,15 @@ private static ClassNode parameterizeLowestUpperBound(final ClassNode lub, final
if (areEqualWithGenerics(t1, isPrimitiveType(a)?getWrapper(a):a) && areEqualWithGenerics(t2, isPrimitiveType(b)?getWrapper(b):b)) {
// "String implements Comparable<String>" and "StringBuffer implements Comparable<StringBuffer>"
basicType = fallback; // do not loop
} else if (ctx.isExpanding(t1, t2)) {
// GROOVY-11770: structural cycle (e.g. LUB(B, D) where B extends A<W<B>>, D extends A<W<D>>)
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says “structural cycle”, but the guard currently detects cycles using identity (TypePair uses ==). Either adjust the wording to reflect identity-based detection, or change the keying strategy to something structural/stable so the comment matches the behavior.

Suggested change
// GROOVY-11770: structural cycle (e.g. LUB(B, D) where B extends A<W<B>>, D extends A<W<D>>)
// GROOVY-11770: recursion guard for an already-expanding type pair (e.g. LUB(B, D) where B extends A<W<B>>, D extends A<W<D>>)

Copilot uses AI. Check for mistakes.
basicType = fallback;
} else {
ctx.enter(t1, t2);
try {
basicType = lowestUpperBound(t1, t2);
} catch (StackOverflowError ignore) {
basicType = fallback; // best we can do for now
basicType = lowestUpperBound(ctx, t1, t2);
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LowestUpperBoundContext uses identity to track in-flight (t1, t2) pairs. However, generics rewriting utilities (e.g., correctToGenericsSpecRecurse/makeClassSafe0) can produce fresh ClassNode instances for the same logical type, which can defeat identity-based cycle detection; with the StackOverflowError catch removed, a missed cycle could reintroduce a hard SOE. Consider keeping the SOE catch as a last-resort backstop, or normalizing the tracked pair to a stable representation (redirect + generics signature) so logically-equal nodes match.

Suggested change
basicType = lowestUpperBound(ctx, t1, t2);
basicType = lowestUpperBound(ctx, t1, t2);
} catch (StackOverflowError ignore) {
// Some generics-rewriting paths can create fresh ClassNode instances for the same
// logical type, which may defeat identity-based cycle tracking in the context.
// Fall back rather than allowing an unbounded recursive expansion to overflow.
basicType = fallback;

Copilot uses AI. Check for mistakes.
} finally {
ctx.exit(t1, t2);
}
}
if (agt[i].isWildcard() || bgt[i].isWildcard() || !t1.equals(t2)) {
Expand All @@ -289,6 +298,50 @@ private static ClassNode parameterizeLowestUpperBound(final ClassNode lub, final
return GenericsUtils.makeClassSafe0(lub, lubGTs);
}

/**
* Tracks pairs of types whose LUB is currently being computed by
* {@link #lowestUpperBound(ClassNode, ClassNode)}, so the recursion can
* break cycles caused by F-bounded type parameters that route a subtype
* back through itself (GROOVY-11770). Pair equality is identity-based and
* order-insensitive, since LUB is symmetric.
*/
private static final class LowestUpperBoundContext {
private final Set<TypePair> inflight = new HashSet<>();

boolean isExpanding(final ClassNode a, final ClassNode b) {
return inflight.contains(new TypePair(a, b));
}

void enter(final ClassNode a, final ClassNode b) {
inflight.add(new TypePair(a, b));
}

void exit(final ClassNode a, final ClassNode b) {
inflight.remove(new TypePair(a, b));
}

private static final class TypePair {
final ClassNode a, b;

TypePair(final ClassNode a, final ClassNode b) {
this.a = a;
this.b = b;
}

@Override
public boolean equals(final Object o) {
if (!(o instanceof TypePair)) return false;
TypePair p = (TypePair) o;
return (a == p.a && b == p.b) || (a == p.b && b == p.a);
}

@Override
public int hashCode() {
return System.identityHashCode(a) ^ System.identityHashCode(b);
}
}
}

private static ClassNode findGenericsTypeHolderForClass(ClassNode source, final ClassNode target) {
if (isPrimitiveType(source)) source = getWrapper(source);
if (source.equals(target)) {
Expand Down
20 changes: 20 additions & 0 deletions src/test/groovy/groovy/transform/stc/GenericsSTCTest.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -4616,6 +4616,26 @@ class GenericsSTCTest extends StaticTypeCheckingTestCase {
}
}

// GROOVY-11770
@Test
void testNoStackOverflow3() {
// Cycle through a wrapper type: LUB(B, D) needs LUB(W<B>, W<D>),
// which needs LUB(B, D) again. Caught by structural cycle guard.
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test comment says the cycle is “caught by structural cycle guard”, but the new guard in WideningCategories is identity-based (and may not be structural if new ClassNode instances are created). Suggest rewording the comment to match the implementation (e.g., “caught by in-flight LUB pair guard”).

Suggested change
// which needs LUB(B, D) again. Caught by structural cycle guard.
// which needs LUB(B, D) again. Caught by the in-flight LUB pair guard.

Copilot uses AI. Check for mistakes.
assertScript '''
class A<T> {}
class W<T> {}
class B extends A<W<B>> {}
class D extends A<W<D>> {}

@groovy.transform.TypeChecked
def test(boolean f) {
def x = f ? new B() : new D()
x.class.name
}
assert test(true) == 'B'
'''
}

@Test
void testRegressionInConstructorCheck() {
assertScript '''
Expand Down
Loading