@@ -232,6 +232,18 @@ private boolean populateWithSavedContainer(IJavaProject project, ContainerResolu
232232 case IClasspathEntry .CPE_PROJECT : {
233233 // projects need to be resolved properly so we have all the output folders and exported jars on the classpath
234234 var sourceProject = workspaceRoot .getProject (e .getPath ().segment (0 ));
235+
236+ // Check for cycles BEFORE calling beginResolvingProject to avoid depth counter issues
237+ if (resolutionContext .processedProjects .contains (sourceProject )) {
238+ if (LOG .isDebugEnabled ()) {
239+ LOG .debug (
240+ "Skipping already processed project '{}' in thread '{}' to avoid cycle" ,
241+ sourceProject .getName (),
242+ Thread .currentThread ().getName ());
243+ }
244+ break ;
245+ }
246+
235247 if (resolutionContext .beginResolvingProject (sourceProject )) {
236248 try {
237249 // only resolve and add the projects if it was never attempted before
@@ -240,12 +252,6 @@ private boolean populateWithSavedContainer(IJavaProject project, ContainerResolu
240252 // remove from stack again when done resolving
241253 resolutionContext .endResolvingProject (sourceProject );
242254 }
243- } else if (LOG .isDebugEnabled ()) {
244- // this should not happen in theory because Bazel is an acyclic graph as well as Eclipse doesn't like it but who knows...
245- LOG .debug (
246- "Skipping recursive resolution attempt for project '{}' in thread '{}' ({})" ,
247- sourceProject ,
248- Thread .currentThread ().getName ());
249255 }
250256 break ;
251257 }
@@ -264,9 +270,74 @@ private boolean populateWithSavedContainer(IJavaProject project, ContainerResolu
264270 }
265271 }
266272
273+ // Add the project's own output folders to the runtime classpath
274+ // This ensures that Eclipse-compiled classes (including test classes) are available at runtime
275+ addProjectOutputFolders (project , resolutionContext );
276+
277+ // Note: Test framework dependencies (like JUnit) are now pre-cached in the container during
278+ // the container build phase (see BazelClasspathManager.saveAndSetContainer), so we no longer
279+ // need to resolve them at runtime. This significantly improves performance for large projects.
280+
267281 return true ;
268282 }
269283
284+ /**
285+ * Checks if the given project is a test project based on naming conventions.
286+ *
287+ * Note: This method is kept for potential future use, but test framework dependencies are now handled during
288+ * container creation rather than runtime resolution.
289+ *
290+ * @param project
291+ * the project to check
292+ * @return <code>true</code> if this appears to be a test project, <code>false</code> otherwise
293+ */
294+ @ SuppressWarnings ("unused" )
295+ private boolean isTestProject (IJavaProject project ) {
296+ var projectName = project .getProject ().getName ();
297+ // Check if project name contains "test" or ends with "-test"
298+ return projectName .contains ("test" ) || projectName .contains ("Test" );
299+ }
300+
301+ /**
302+ * Adds the project's own output folders to the runtime classpath. This includes both the regular output folder
303+ * (eclipse-bin) and the test output folder (eclipse-testbin).
304+ *
305+ * @param project
306+ * the project whose output folders should be added
307+ * @param resolutionContext
308+ * the resolution context
309+ * @throws CoreException
310+ */
311+ private void addProjectOutputFolders (IJavaProject project , ContainerResolutionContext resolutionContext )
312+ throws CoreException {
313+ // Add the project's default output location (main compiled classes)
314+ var defaultOutputLocation = project .getOutputLocation ();
315+ if (defaultOutputLocation != null ) {
316+ var outputEntry = JavaRuntime .newArchiveRuntimeClasspathEntry (defaultOutputLocation );
317+ outputEntry .setClasspathProperty (IRuntimeClasspathEntry .USER_CLASSES );
318+ resolutionContext .add (outputEntry );
319+ if (LOG .isDebugEnabled ()) {
320+ LOG .debug ("Added default output location to runtime classpath: {}" , defaultOutputLocation );
321+ }
322+ }
323+
324+ // For Bazel projects, also add the test output folder explicitly
325+ var bazelProject = BazelCore .create (project .getProject ());
326+ if (bazelProject != null ) {
327+ var fileSystemMapper = bazelProject .getBazelWorkspace ().getBazelProjectFileSystemMapper ();
328+ var testOutputFolder = fileSystemMapper .getOutputFolderForTests (bazelProject );
329+ // Test output folder might be different from the default output location
330+ if (!testOutputFolder .getFullPath ().equals (defaultOutputLocation )) {
331+ var testOutputEntry = JavaRuntime .newArchiveRuntimeClasspathEntry (testOutputFolder .getFullPath ());
332+ testOutputEntry .setClasspathProperty (IRuntimeClasspathEntry .USER_CLASSES );
333+ resolutionContext .add (testOutputEntry );
334+ if (LOG .isDebugEnabled ()) {
335+ LOG .debug ("Added test output folder to runtime classpath: {}" , testOutputFolder .getFullPath ());
336+ }
337+ }
338+ }
339+ }
340+
270341 @ Override
271342 public IRuntimeClasspathEntry [] resolveRuntimeClasspathEntry (IRuntimeClasspathEntry entry , IJavaProject project )
272343 throws CoreException {
@@ -289,15 +360,31 @@ public IRuntimeClasspathEntry[] resolveRuntimeClasspathEntry(IRuntimeClasspathEn
289360 // this method can be entered recursively; luckily only within the same thread
290361 // therefore we use a ThreadLocal LinkedHashSet to keep track of recursive attempts
291362 var resolutionContext = currentThreadResolutionContet .get ();
292- if (!resolutionContext .beginResolvingProject (project .getProject ())) {
363+
364+ // CRITICAL: Check if this is top-level BEFORE incrementing depth
365+ // This ensures ThreadLocal cleanup happens correctly
366+ var isTopLevelResolution = resolutionContext .currentDepth == 0 ;
367+
368+ // Check for recursive resolution BEFORE calling beginResolvingProject
369+ // to avoid depth counter mismatch
370+ if (resolutionContext .processedProjects .contains (project .getProject ())) {
293371 LOG .warn (
294- "Detected recursive resolution attempt for project '{}' in thread '{}' ({})" ,
372+ "Detected recursive resolution attempt for project '{}' in thread '{}' - skipping to avoid cycle" ,
373+ project .getProject ().getName (),
374+ Thread .currentThread ().getName ());
375+ return new IRuntimeClasspathEntry [0 ];
376+ }
377+
378+ // Now safe to increment depth counter
379+ if (!resolutionContext .beginResolvingProject (project .getProject ())) {
380+ // This should never happen now due to the check above, but keep as safety net
381+ LOG .error (
382+ "Unexpected state: beginResolvingProject returned false after cycle check for project '{}' in thread '{}'" ,
295383 project .getProject ().getName (),
296384 Thread .currentThread ().getName ());
297385 return new IRuntimeClasspathEntry [0 ];
298386 }
299387
300- var isTopLevelResolution = resolutionContext .currentDepth == 0 ;
301388 var stopWatch = StopWatch .startNewStopWatch ();
302389 try {
303390 // try the saved container
0 commit comments