From 97e0045bc2d0da5d2d7242b28458d8237c9f0530 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Martin?= Date: Wed, 15 Jul 2026 20:11:30 +0200 Subject: [PATCH 1/3] Fix nested lambda inference memory growth #5206 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Link nested speculative copies to their canonical source lambdas so parameterless lambda chains reuse existing per-target inference results without sharing parameter bindings or parser state. Fixes https://github.com/eclipse-jdt/eclipse.jdt.core/issues/5206 Signed-off-by: François Martin --- .../compiler/ast/LambdaExpression.java | 55 ++++++++++- .../regression/NestedLambdaInferenceTest.java | 94 +++++++++++++++++++ .../tests/compiler/regression/TestAll.java | 1 + 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 org.eclipse.jdt.core.tests.compiler/src/org/eclipse/jdt/core/tests/compiler/regression/NestedLambdaInferenceTest.java diff --git a/org.eclipse.jdt.core.compiler.batch/src/org/eclipse/jdt/internal/compiler/ast/LambdaExpression.java b/org.eclipse.jdt.core.compiler.batch/src/org/eclipse/jdt/internal/compiler/ast/LambdaExpression.java index 0cf0f123d9c..c64191aad7f 100644 --- a/org.eclipse.jdt.core.compiler.batch/src/org/eclipse/jdt/internal/compiler/ast/LambdaExpression.java +++ b/org.eclipse.jdt.core.compiler.batch/src/org/eclipse/jdt/internal/compiler/ast/LambdaExpression.java @@ -47,6 +47,7 @@ import static org.eclipse.jdt.internal.compiler.ast.ExpressionContext.INVOCATION_CONTEXT; +import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedHashSet; import java.util.List; @@ -1116,7 +1117,7 @@ LambdaExpression copy() { if (copy != null) { // ==> syntax errors == null if (copy.sourceStart != this.sourceStart || copy.sourceEnd != this.sourceEnd) return null; // something wrong - copy.original = this; + shareInferenceCaches(copy); copy.assistNode = this.assistNode; copy.enclosingScope = this.enclosingScope; copy.text = this.text; // discard redundant textual copy @@ -1124,6 +1125,58 @@ LambdaExpression copy() { return copy; } + private void shareInferenceCaches(LambdaExpression copy) { + // A speculative copy can contain nested lambdas. Keep each nested copy linked to its + // source lambda when no enclosing lambda declares parameters, so overload checks can + // reuse the existing per-target inference cache without reusing parameter bindings. + List sourceLambdas = collectLambdas(this); + List copiedLambdas = collectLambdas(copy); + if (sourceLambdas.size() != copiedLambdas.size()) + throw new CopyFailureException(); + for (int i = 0; i < sourceLambdas.size(); i++) { + CollectedLambda source = sourceLambdas.get(i); + LambdaExpression sourceLambda = source.lambda(); + LambdaExpression copiedLambda = copiedLambdas.get(i).lambda(); + if (sourceLambda.sourceStart != copiedLambda.sourceStart || sourceLambda.sourceEnd != copiedLambda.sourceEnd) + throw new CopyFailureException(); + if (!source.cacheShareable()) { + if (i == 0) + copiedLambda.original = this; + continue; + } + LambdaExpression originalLambda = sourceLambda.original; + copiedLambda.original = originalLambda; + if (originalLambda.copiesPerTargetType == null) + originalLambda.copiesPerTargetType = new HashMap<>(); + sourceLambda.copiesPerTargetType = originalLambda.copiesPerTargetType; + copiedLambda.copiesPerTargetType = originalLambda.copiesPerTargetType; + } + } + + private record CollectedLambda(LambdaExpression lambda, boolean cacheShareable) {} + + private static List collectLambdas(LambdaExpression root) { + List lambdas = new ArrayList<>(); + root.traverse(new ASTVisitor() { + private int parameterizedLambdaDepth; + + @Override + public boolean visit(LambdaExpression lambda, BlockScope skope) { + if (lambda.arguments.length > 0) + this.parameterizedLambdaDepth++; + lambdas.add(new CollectedLambda(lambda, this.parameterizedLambdaDepth == 0)); + return true; + } + + @Override + public void endVisit(LambdaExpression lambda, BlockScope skope) { + if (lambda.arguments.length > 0) + this.parameterizedLambdaDepth--; + } + }, root.enclosingScope); + return lambdas; + } + public void returnsExpression(Expression expression, TypeBinding resultType) { if (this.original == this) // Not in overload resolution context. result expressions not relevant. return; diff --git a/org.eclipse.jdt.core.tests.compiler/src/org/eclipse/jdt/core/tests/compiler/regression/NestedLambdaInferenceTest.java b/org.eclipse.jdt.core.tests.compiler/src/org/eclipse/jdt/core/tests/compiler/regression/NestedLambdaInferenceTest.java new file mode 100644 index 00000000000..df039e441c0 --- /dev/null +++ b/org.eclipse.jdt.core.tests.compiler/src/org/eclipse/jdt/core/tests/compiler/regression/NestedLambdaInferenceTest.java @@ -0,0 +1,94 @@ +/******************************************************************************* + * Copyright (c) 2026 François Martin and others. + * + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * François Martin - initial API and implementation + *******************************************************************************/ +package org.eclipse.jdt.core.tests.compiler.regression; + +import junit.framework.Test; + +public class NestedLambdaInferenceTest extends AbstractRegressionTest { + private static final int NESTING_DEPTH = 24; + + public NestedLambdaInferenceTest(String name) { + super(name); + } + + public static Test suite() { + return buildMinimalComplianceTestSuite(NestedLambdaInferenceTest.class, F_1_8); + } + + // https://github.com/eclipse-jdt/eclipse.jdt.core/issues/5206 + public void testIssue5206GenericRouteChain() { + runNestedLambdaTest("GenericRouteChain", """ + V route(Red marker, Work work) { return null; } + V route(Blue marker, Work work) { return null; } + V route(Green marker, Work work) { return null; } + V route(Gold marker, Work work) { return null; } + V route(Object marker, Work work) { return null; } + + V select(Red marker, Work work) { return null; } + V select(Blue marker, Work work) { return null; } + V select(Green marker, Work work) { return null; } + V select(Gold marker, Work work) { return null; } + V select(Object marker, Work work) { return null; } + """); + } + + // https://github.com/eclipse-jdt/eclipse.jdt.core/issues/5206 + public void testIssue5206ConcreteRouteChain() { + runNestedLambdaTest("ConcreteRouteChain", """ + String route(Red marker, Work work) { return ""; } + String route(Blue marker, Work work) { return ""; } + String route(Green marker, Work work) { return ""; } + String route(Gold marker, Work work) { return ""; } + String route(Object marker, Work work) { return ""; } + + String select(Red marker, Work work) { return ""; } + String select(Blue marker, Work work) { return ""; } + String select(Green marker, Work work) { return ""; } + String select(Gold marker, Work work) { return ""; } + String select(Object marker, Work work) { return ""; } + """); + } + + private void runNestedLambdaTest(String className, String overloads) { + this.runConformTest(new String[] { + className + ".java", + """ + class %s { + interface Work { + V perform(); + } + static final Work TERMINAL = null; + + void test() { + %s; + } + static class Red { } + static class Blue { } + static class Green { } + static class Gold { } + %s + } + """.formatted(className, createNestedInvocation(), overloads.indent(4).stripTrailing()) + }); + } + + private static String createNestedInvocation() { + String invocation = "route(null, TERMINAL)"; + for (int level = 1; level < NESTING_DEPTH; level++) { + String selector = level % 2 == 0 ? "route" : "select"; + invocation = selector + "(null, () -> " + invocation + ")"; + } + return invocation; + } +} diff --git a/org.eclipse.jdt.core.tests.compiler/src/org/eclipse/jdt/core/tests/compiler/regression/TestAll.java b/org.eclipse.jdt.core.tests.compiler/src/org/eclipse/jdt/core/tests/compiler/regression/TestAll.java index b8875da2eae..14087ca36b5 100644 --- a/org.eclipse.jdt.core.tests.compiler/src/org/eclipse/jdt/core/tests/compiler/regression/TestAll.java +++ b/org.eclipse.jdt.core.tests.compiler/src/org/eclipse/jdt/core/tests/compiler/regression/TestAll.java @@ -131,6 +131,7 @@ public static Test suite() { standardTests.add(NullTypeAnnotationTest.class); standardTests.add(NegativeLambdaExpressionsTest.class); standardTests.add(LambdaExpressionsTest.class); + standardTests.add(NestedLambdaInferenceTest.class); standardTests.add(LambdaRegressionTest.class); standardTests.add(SerializableLambdaTest.class); standardTests.add(OverloadResolutionTest8.class); From f1083f93c1e8fbbdc029a54134a04df5ccf8a30a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Martin?= Date: Sat, 1 Aug 2026 13:45:42 +0200 Subject: [PATCH 2/3] Explain nested lambda cache ownership MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the traversal-index root check with an identity check, document context-local cache ownership below parameterized lambdas, and cover interleaved parameterized and parameterless lambdas. Signed-off-by: François Martin --- .../compiler/ast/LambdaExpression.java | 6 +++- .../regression/NestedLambdaInferenceTest.java | 34 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/org.eclipse.jdt.core.compiler.batch/src/org/eclipse/jdt/internal/compiler/ast/LambdaExpression.java b/org.eclipse.jdt.core.compiler.batch/src/org/eclipse/jdt/internal/compiler/ast/LambdaExpression.java index c64191aad7f..d0a7a1d1647 100644 --- a/org.eclipse.jdt.core.compiler.batch/src/org/eclipse/jdt/internal/compiler/ast/LambdaExpression.java +++ b/org.eclipse.jdt.core.compiler.batch/src/org/eclipse/jdt/internal/compiler/ast/LambdaExpression.java @@ -1129,6 +1129,7 @@ private void shareInferenceCaches(LambdaExpression copy) { // A speculative copy can contain nested lambdas. Keep each nested copy linked to its // source lambda when no enclosing lambda declares parameters, so overload checks can // reuse the existing per-target inference cache without reusing parameter bindings. + // Both traversals start with their root and then visit nested lambdas in source order. List sourceLambdas = collectLambdas(this); List copiedLambdas = collectLambdas(copy); if (sourceLambdas.size() != copiedLambdas.size()) @@ -1140,7 +1141,10 @@ private void shareInferenceCaches(LambdaExpression copy) { if (sourceLambda.sourceStart != copiedLambda.sourceStart || sourceLambda.sourceEnd != copiedLambda.sourceEnd) throw new CopyFailureException(); if (!source.cacheShareable()) { - if (i == 0) + // The root is the copy requested by the caller. Nested lambdas below a + // parameterized lambda are local sources for copies that use the enclosing + // copy's parameter bindings, so their inference caches stay in that context. + if (sourceLambda == this) copiedLambda.original = this; continue; } diff --git a/org.eclipse.jdt.core.tests.compiler/src/org/eclipse/jdt/core/tests/compiler/regression/NestedLambdaInferenceTest.java b/org.eclipse.jdt.core.tests.compiler/src/org/eclipse/jdt/core/tests/compiler/regression/NestedLambdaInferenceTest.java index df039e441c0..2ff384e13f0 100644 --- a/org.eclipse.jdt.core.tests.compiler/src/org/eclipse/jdt/core/tests/compiler/regression/NestedLambdaInferenceTest.java +++ b/org.eclipse.jdt.core.tests.compiler/src/org/eclipse/jdt/core/tests/compiler/regression/NestedLambdaInferenceTest.java @@ -60,6 +60,40 @@ public void testIssue5206ConcreteRouteChain() { """); } + // https://github.com/eclipse-jdt/eclipse.jdt.core/issues/5206 + public void testIssue5206InterleavedParameterizedLambdas() { + this.runConformTest(new String[] { + "InterleavedLambdas.java", + """ + public class InterleavedLambdas { + interface Producer { + T produce(); + } + interface Mapper { + R map(T value); + } + + static T produce(Producer producer) { + return producer.produce(); + } + static R map(T value, Mapper mapper) { + return mapper.map(value); + } + + public static void main(String[] args) { + String result = produce(() -> + map("left", left -> + produce(() -> + map(7, number -> left + number))) + + produce(() -> "!")); + System.out.print(result); + } + } + """ + }, + "left7!"); + } + private void runNestedLambdaTest(String className, String overloads) { this.runConformTest(new String[] { className + ".java", From b25d5f0f9416f6ad5d1dab31f9336161e656b109 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Martin?= Date: Thu, 6 Aug 2026 17:15:56 +0200 Subject: [PATCH 3/3] Prune inference cache sharing below lambdas with parameters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collect a lambda with parameters and then stop below it. Nested lambdas may use that lambda's parameter bindings, so their caches must stay local to the enclosing copy. The lambda itself can share its cache because each cached copy has its own parameter bindings. Remove the cache eligibility record and depth counter. Let shareInferenceCaches() create copiesPerTargetType in one place. Signed-off-by: François Martin --- .../compiler/ast/LambdaExpression.java | 52 ++++++------------- 1 file changed, 16 insertions(+), 36 deletions(-) diff --git a/org.eclipse.jdt.core.compiler.batch/src/org/eclipse/jdt/internal/compiler/ast/LambdaExpression.java b/org.eclipse.jdt.core.compiler.batch/src/org/eclipse/jdt/internal/compiler/ast/LambdaExpression.java index d0a7a1d1647..ac702348165 100644 --- a/org.eclipse.jdt.core.compiler.batch/src/org/eclipse/jdt/internal/compiler/ast/LambdaExpression.java +++ b/org.eclipse.jdt.core.compiler.batch/src/org/eclipse/jdt/internal/compiler/ast/LambdaExpression.java @@ -986,9 +986,7 @@ private LambdaExpression cachedResolvedCopy(TypeBinding targetType, boolean anyT return null; targetType = copy.expectedType; // possibly updated local types - if (this.copiesPerTargetType == null) - this.copiesPerTargetType = new HashMap<>(); - this.copiesPerTargetType.put(targetType, copy); + this.copiesPerTargetType.put(targetType, copy); // copy() has linked this lambda to the original's cache } if (!requireExceptionAnalysis) return copy; @@ -1126,28 +1124,22 @@ LambdaExpression copy() { } private void shareInferenceCaches(LambdaExpression copy) { - // A speculative copy can contain nested lambdas. Keep each nested copy linked to its - // source lambda when no enclosing lambda declares parameters, so overload checks can - // reuse the existing per-target inference cache without reusing parameter bindings. - // Both traversals start with their root and then visit nested lambdas in source order. - List sourceLambdas = collectLambdas(this); - List copiedLambdas = collectLambdas(copy); + // A speculative copy can contain nested lambdas. Link each collected lambda to its + // original lambda and share the per-target inference cache. + // A cache entry is a resolved lambda copy and owns its parameter bindings. Nested + // lambdas may use parameters from an enclosing lambda, so collectLambdas() stops + // below a lambda with parameters. Caches below that point stay local to the + // enclosing copy. + // Both traversals start at the root and visit nested lambdas in source order. + List sourceLambdas = collectLambdas(this); + List copiedLambdas = collectLambdas(copy); if (sourceLambdas.size() != copiedLambdas.size()) throw new CopyFailureException(); for (int i = 0; i < sourceLambdas.size(); i++) { - CollectedLambda source = sourceLambdas.get(i); - LambdaExpression sourceLambda = source.lambda(); - LambdaExpression copiedLambda = copiedLambdas.get(i).lambda(); + LambdaExpression sourceLambda = sourceLambdas.get(i); + LambdaExpression copiedLambda = copiedLambdas.get(i); if (sourceLambda.sourceStart != copiedLambda.sourceStart || sourceLambda.sourceEnd != copiedLambda.sourceEnd) throw new CopyFailureException(); - if (!source.cacheShareable()) { - // The root is the copy requested by the caller. Nested lambdas below a - // parameterized lambda are local sources for copies that use the enclosing - // copy's parameter bindings, so their inference caches stay in that context. - if (sourceLambda == this) - copiedLambda.original = this; - continue; - } LambdaExpression originalLambda = sourceLambda.original; copiedLambda.original = originalLambda; if (originalLambda.copiesPerTargetType == null) @@ -1157,25 +1149,13 @@ private void shareInferenceCaches(LambdaExpression copy) { } } - private record CollectedLambda(LambdaExpression lambda, boolean cacheShareable) {} - - private static List collectLambdas(LambdaExpression root) { - List lambdas = new ArrayList<>(); + private static List collectLambdas(LambdaExpression root) { + List lambdas = new ArrayList<>(); root.traverse(new ASTVisitor() { - private int parameterizedLambdaDepth; - @Override public boolean visit(LambdaExpression lambda, BlockScope skope) { - if (lambda.arguments.length > 0) - this.parameterizedLambdaDepth++; - lambdas.add(new CollectedLambda(lambda, this.parameterizedLambdaDepth == 0)); - return true; - } - - @Override - public void endVisit(LambdaExpression lambda, BlockScope skope) { - if (lambda.arguments.length > 0) - this.parameterizedLambdaDepth--; + lambdas.add(lambda); + return lambda.arguments.length == 0; // nested lambdas may use these parameters } }, root.enclosingScope); return lambdas;