allRemainingURIs, final IRe
}
}
+ /**
+ * Whether a load failure means the load operation timed out and abandoned all resources it had not delivered yet.
+ *
+ * @param exception
+ * the exception thrown by {@link IResourceLoader.LoadOperation#next()}, must not be {@code null}
+ * @param loadOperation
+ * the load operation that threw it, must not be {@code null}
+ * @return {@code true} if the operation was abandoned after a timeout
+ */
+ public static boolean isAbandonedByTimeout(final WrappedException exception, final IResourceLoader.LoadOperation loadOperation) {
+ return exception instanceof LoadOperationException && exception.getCause() instanceof TimeoutException && !loadOperation.hasNext();
+ }
+
/**
* Checks if the given {@link IProgressMonitor} was cancelled.
*
diff --git a/com.avaloq.tools.ddk.xtext.builder/src/com/avaloq/tools/ddk/xtext/builder/resourceloader/ParallelResourceLoader.java b/com.avaloq.tools.ddk.xtext.builder/src/com/avaloq/tools/ddk/xtext/builder/resourceloader/ParallelResourceLoader.java
index 26e570a79a..50f6ada2e9 100644
--- a/com.avaloq.tools.ddk.xtext.builder/src/com/avaloq/tools/ddk/xtext/builder/resourceloader/ParallelResourceLoader.java
+++ b/com.avaloq.tools.ddk.xtext.builder/src/com/avaloq/tools/ddk/xtext/builder/resourceloader/ParallelResourceLoader.java
@@ -216,9 +216,14 @@ public LoadResult next() {
throw new NoSuchElementException("The resource queue is empty or the execution was cancelled."); //$NON-NLS-1$
}
Triple result = null;
+ boolean timedOut = false;
try {
result = resourceQueue.poll(waitTime, TimeUnit.MILLISECONDS);
- toProcess--;
+ if (result != null) {
+ toProcess--;
+ } else {
+ timedOut = true;
+ }
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
@@ -227,7 +232,13 @@ public LoadResult next() {
synchronized (currentlyProcessedUris) {
currentUris = Joiner.on(", ").join(currentlyProcessedUris); //$NON-NLS-1$
}
- throw new LoadOperationException(null, new TimeoutException(String.format("Resource load job didn't return a result after %d ms. Resources being currently loaded: %s", waitTime, currentUris))); //$NON-NLS-1$
+ String message = String.format("Resource load job didn't return a result after %d ms. Resources being currently loaded: %s", waitTime, currentUris); //$NON-NLS-1$
+ if (timedOut) {
+ // a timeout cannot be attributed to a URI and a hung load would never finish, so abandon the whole operation
+ cancel();
+ message += " The remaining loads are abandoned."; //$NON-NLS-1$
+ }
+ throw new LoadOperationException(null, new TimeoutException(message));
}
URI uri = result.getFirst();
diff --git a/com.avaloq.tools.ddk.xtext.test/src/com/avaloq/tools/ddk/xtext/XtextTestSuite.java b/com.avaloq.tools.ddk.xtext.test/src/com/avaloq/tools/ddk/xtext/XtextTestSuite.java
index 2a6b079e5d..e563412993 100644
--- a/com.avaloq.tools.ddk.xtext.test/src/com/avaloq/tools/ddk/xtext/XtextTestSuite.java
+++ b/com.avaloq.tools.ddk.xtext.test/src/com/avaloq/tools/ddk/xtext/XtextTestSuite.java
@@ -13,7 +13,9 @@
import org.junit.platform.suite.api.SelectClasses;
import org.junit.platform.suite.api.Suite;
+import com.avaloq.tools.ddk.xtext.builder.BuilderLoadTimeoutTest;
import com.avaloq.tools.ddk.xtext.builder.XtextBuildTriggerTest;
+import com.avaloq.tools.ddk.xtext.builder.resourceloader.ParallelResourceLoaderTest;
import com.avaloq.tools.ddk.xtext.jupiter.formatter.FormatterTest;
import com.avaloq.tools.ddk.xtext.linking.AbstractFragmentProviderTest;
import com.avaloq.tools.ddk.xtext.linking.ShortFragmentProviderTest;
@@ -36,6 +38,8 @@
AbstractSelectorFragmentProviderTest.class,
ResourceDescriptionDeltaTest.class,
XtextBuildTriggerTest.class,
+ BuilderLoadTimeoutTest.class,
+ ParallelResourceLoaderTest.class,
FormatterTest.class,
QualifiedNamePatternTest.class,
BugAig1084.class,
diff --git a/com.avaloq.tools.ddk.xtext.test/src/com/avaloq/tools/ddk/xtext/builder/BuilderLoadTimeoutTest.java b/com.avaloq.tools.ddk.xtext.test/src/com/avaloq/tools/ddk/xtext/builder/BuilderLoadTimeoutTest.java
new file mode 100644
index 0000000000..8cacbdab11
--- /dev/null
+++ b/com.avaloq.tools.ddk.xtext.test/src/com/avaloq/tools/ddk/xtext/builder/BuilderLoadTimeoutTest.java
@@ -0,0 +1,63 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Avaloq Group AG and others.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v10.html
+ *
+ * Contributors:
+ * Avaloq Group AG - initial API and implementation
+ *******************************************************************************/
+package com.avaloq.tools.ddk.xtext.builder;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.io.IOException;
+import java.util.concurrent.TimeoutException;
+
+import org.eclipse.emf.common.util.URI;
+import org.eclipse.emf.common.util.WrappedException;
+import org.eclipse.xtext.builder.resourceloader.IResourceLoader.LoadOperation;
+import org.eclipse.xtext.builder.resourceloader.IResourceLoader.LoadOperationException;
+import org.junit.jupiter.api.Test;
+
+
+/**
+ * Tests for {@link MonitoredClusteringBuilderState#isAbandonedByTimeout(WrappedException, LoadOperation)}.
+ */
+@SuppressWarnings("nls")
+public class BuilderLoadTimeoutTest {
+
+ private static final TimeoutException TIMEOUT = new TimeoutException("no result");
+
+ @Test
+ public void timeoutWithNothingLeftIsAbandonment() {
+ assertTrue(MonitoredClusteringBuilderState.isAbandonedByTimeout(new LoadOperationException(null, TIMEOUT), operation(false)));
+ }
+
+ @Test
+ public void timeoutWithLoadsLeftIsNotAbandonment() {
+ assertFalse(MonitoredClusteringBuilderState.isAbandonedByTimeout(new LoadOperationException(null, TIMEOUT), operation(true)));
+ }
+
+ @Test
+ public void loadFailureIsNotAbandonment() {
+ LoadOperationException failure = new LoadOperationException(URI.createURI("platform:/resource/project/a.test"), new IOException("cannot read"));
+ assertFalse(MonitoredClusteringBuilderState.isAbandonedByTimeout(failure, operation(false)));
+ }
+
+ @Test
+ public void otherWrappedExceptionIsNotAbandonment() {
+ assertFalse(MonitoredClusteringBuilderState.isAbandonedByTimeout(new WrappedException(TIMEOUT), operation(false)));
+ }
+
+ private static LoadOperation operation(final boolean hasNext) {
+ LoadOperation operation = mock(LoadOperation.class);
+ when(operation.hasNext()).thenReturn(hasNext);
+ return operation;
+ }
+
+}
diff --git a/com.avaloq.tools.ddk.xtext.test/src/com/avaloq/tools/ddk/xtext/builder/resourceloader/ParallelResourceLoaderTest.java b/com.avaloq.tools.ddk.xtext.test/src/com/avaloq/tools/ddk/xtext/builder/resourceloader/ParallelResourceLoaderTest.java
new file mode 100644
index 0000000000..3d763c0766
--- /dev/null
+++ b/com.avaloq.tools.ddk.xtext.test/src/com/avaloq/tools/ddk/xtext/builder/resourceloader/ParallelResourceLoaderTest.java
@@ -0,0 +1,151 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Avaloq Group AG and others.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v10.html
+ *
+ * Contributors:
+ * Avaloq Group AG - initial API and implementation
+ *******************************************************************************/
+package com.avaloq.tools.ddk.xtext.builder.resourceloader;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.function.Function;
+
+import org.eclipse.emf.common.util.URI;
+import org.eclipse.emf.common.util.WrappedException;
+import org.eclipse.emf.ecore.resource.Resource;
+import org.eclipse.emf.ecore.resource.ResourceSet;
+import org.eclipse.emf.ecore.resource.impl.ResourceImpl;
+import org.eclipse.emf.ecore.resource.impl.ResourceSetImpl;
+import org.eclipse.xtext.builder.resourceloader.IResourceLoader.LoadOperation;
+import org.eclipse.xtext.builder.resourceloader.IResourceLoader.LoadOperationException;
+import org.eclipse.xtext.builder.resourceloader.IResourceLoader.Sorter;
+import org.eclipse.xtext.resource.IResourceServiceProvider;
+import org.eclipse.xtext.resource.persistence.SourceLevelURIsAdapter;
+import org.eclipse.xtext.ui.resource.IResourceSetProvider;
+import org.junit.jupiter.api.Test;
+
+import com.google.common.util.concurrent.Uninterruptibles;
+import com.google.inject.Guice;
+
+
+/**
+ * Tests for {@link ParallelResourceLoader}.
+ */
+@SuppressWarnings("nls")
+public class ParallelResourceLoaderTest {
+
+ private static final URI SLOW_URI = URI.createURI("platform:/resource/project/slow.test");
+ private static final URI OTHER_URI = URI.createURI("platform:/resource/project/other.test");
+ private static final long TIMEOUT_MILLIS = 50;
+ private static final long GENEROUS_TIMEOUT_MILLIS = TimeUnit.SECONDS.toMillis(10);
+ private static final long SETTLE_MILLIS = 200;
+ /** Unbounded result queue, and the synchronous hand-off used in production. */
+ private static final int[] QUEUE_SIZES = {-1, 0};
+
+ @Test
+ public void timeoutAbandonsEveryOutstandingLoad() {
+ for (int queueSize : QUEUE_SIZES) {
+ CountDownLatch loadReleased = new CountDownLatch(1);
+ Set loaded = ConcurrentHashMap.newKeySet();
+ LoadOperation operation = load(List.of(SLOW_URI, OTHER_URI), queueSize, TIMEOUT_MILLIS, uri -> {
+ loaded.add(uri);
+ Uninterruptibles.awaitUninterruptibly(loadReleased);
+ return new ResourceImpl(uri);
+ });
+ try {
+ assertTimesOut(operation);
+ assertFalse(operation.hasNext(), "one timeout must abandon all outstanding loads, not just one");
+
+ loadReleased.countDown();
+ Uninterruptibles.sleepUninterruptibly(SETTLE_MILLIS, TimeUnit.MILLISECONDS);
+ assertFalse(loaded.contains(OTHER_URI), "queued loads must not start after the operation was abandoned");
+ } finally {
+ loadReleased.countDown();
+ operation.cancel();
+ }
+ }
+ }
+
+ @Test
+ public void failedLoadDoesNotAbandonTheOperation() {
+ for (int queueSize : QUEUE_SIZES) {
+ LoadOperation operation = load(List.of(SLOW_URI, OTHER_URI), queueSize, GENEROUS_TIMEOUT_MILLIS, uri -> {
+ if (SLOW_URI.equals(uri)) {
+ throw new WrappedException(new IOException("cannot read"));
+ }
+ return new ResourceImpl(uri);
+ });
+ try {
+ LoadOperationException failure = assertThrows(LoadOperationException.class, operation::next);
+ assertSame(SLOW_URI, failure.getUri());
+ assertInstanceOf(IOException.class, failure.getCause());
+ assertTrue(operation.hasNext(), "a failed load must not abandon the remaining loads");
+ assertSame(OTHER_URI, operation.next().getResource().getURI());
+ } finally {
+ operation.cancel();
+ }
+ }
+ }
+
+ @Test
+ public void resultWithinTimeoutIsDelivered() {
+ for (int queueSize : QUEUE_SIZES) {
+ LoadOperation operation = load(List.of(SLOW_URI), queueSize, GENEROUS_TIMEOUT_MILLIS, ResourceImpl::new);
+ try {
+ assertSame(SLOW_URI, operation.next().getResource().getURI());
+ assertFalse(operation.hasNext(), "the single result has been delivered");
+ } finally {
+ operation.cancel();
+ }
+ }
+ }
+
+ private static LoadOperation load(final List uris, final int queueSize, final long timeoutMillis, final Function loadFunction) {
+ ParallelResourceLoader loader = new ParallelResourceLoader(resourceSetProvider(), new Sorter.NoSorting(), 1, queueSize) {
+ @Override
+ protected Resource loadResource(final URI uri, final ResourceSet localResourceSet, final ResourceSet parentResourceSet) {
+ return loadFunction.apply(uri);
+ }
+ };
+ IResourceServiceProvider.Registry registry = mock(IResourceServiceProvider.Registry.class);
+ Guice.createInjector(binder -> binder.bind(IResourceServiceProvider.Registry.class).toInstance(registry)).injectMembers(loader);
+ loader.setTimeout(timeoutMillis, TimeUnit.MILLISECONDS);
+
+ ResourceSet parent = new ResourceSetImpl();
+ SourceLevelURIsAdapter.setSourceLevelUris(parent, Collections.emptySet());
+ LoadOperation operation = loader.create(parent, null);
+ operation.load(uris);
+ return operation;
+ }
+
+ private static void assertTimesOut(final LoadOperation operation) {
+ LoadOperationException timeout = assertThrows(LoadOperationException.class, operation::next);
+ assertInstanceOf(TimeoutException.class, timeout.getCause());
+ }
+
+ private static IResourceSetProvider resourceSetProvider() {
+ IResourceSetProvider provider = mock(IResourceSetProvider.class);
+ when(provider.get(any())).thenAnswer(invocation -> new ResourceSetImpl());
+ return provider;
+ }
+
+}