From 4d0b7be3548eb2dd6ba2d2e703d8f0fd497a802f Mon Sep 17 00:00:00 2001
From: Toine Hartman
Date: Fri, 11 Sep 2026 16:15:46 +0200
Subject: [PATCH 01/32] Extract map compute to outer function.
---
.../routing/MultipleClientProxy.java | 145 +++++++++---------
1 file changed, 70 insertions(+), 75 deletions(-)
diff --git a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/routing/MultipleClientProxy.java b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/routing/MultipleClientProxy.java
index c1d9990b1..06404bbaa 100644
--- a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/routing/MultipleClientProxy.java
+++ b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/routing/MultipleClientProxy.java
@@ -220,7 +220,7 @@ public CompletableFuture registerCapability(RegistrationParams params) {
.reduce(params
.getRegistrations()
.parallelStream()
- .map(this::registerCapability), exec)
+ .map(r -> wrapResult(registrations.compute(r.getMethod(), (method, existingRegistrationsByOptions) -> registerCapability(r, computeIfAbsent(existingRegistrationsByOptions))))), exec)
.thenAccept(v -> {}); // convert to Void
}
@@ -228,6 +228,12 @@ private static CompletableFuture
*/
private class CapabilityRegistry {
- private final Scheduler scheduler = new Scheduler<>(exec);
+ private final Scheduler scheduler = new Scheduler<>();
private final Map>> sentByServers = new ConcurrentHashMap<>();
private final Map> receivedByClient = new ConcurrentHashMap<>();
// Notes:
@@ -404,6 +404,10 @@ public CompletableFuture unregisterCapability(Unregistration u) {
// Find the corresponding registration previously received by the client
var toClient = MapOfMaps.get(receivedByClient, method, options);
if (toClient == null) {
+ // This should never happen: instances of this class are intended to preserve the consistency
+ // invariant that the number of registrations of a capability in `sentByServers` is greater than
+ // 0 if, and only if, there is a registration of that capability in `receivedByClient`. So, if
+ // `remaining == 1`, but `toClient == null`, then the invariant is broken.
var t = new IllegalStateException("Cannot unregister a capability for which no registration was received by the client");
logger.trace("Unregister capability {} ({}). Failed: {}", method, id, t);
result.completeExceptionally(t);
@@ -448,158 +452,150 @@ private CompletableFuture forwardUnregistration(String id, Strin
return client.unregisterCapability(new UnregistrationParams(List.of(u))).thenApply(_void -> u);
}
}
-}
-
-/**
- *
- * Basic lock-free scheduler that requires submitted tasks to signal their completion explicitly (and possibly
- * asynchronously). Only after the current task has signaled its completion will the next task be started.
- *
- *
- *
- * Each task is represented as a pair that consists of: (1) a closure that represents the work of the task, with a
- * formal parameter of type {@link CompletableFuture}, and (2) a future that represents the result of the task, which is
- * passed to the closure as actual parameter when the task is started. The body of the closure must eventually call
- * {@link CompletableFuture#complete} (or any other {@code complete...} method) on the future to signal its completion
- * and provide the result. Not performing such a call causes the scheduler to get stuck.
- *
- *
- *
- * When a new task is submitted, and if no existing task is in progress yet, then the new task is started immediately.
- * In contrast, if an existing task is in progress already, then the new task is started when the current task and all
- * other pending existing tasks in the queue have signaled their completion. To this end, attempts to start tasks are
- * made in two places: in the method to submit a new task, and in the closure that is run when an existing task has
- * signaled its completion.
- *
- *
- * @param R Result type of tasks
- */
-class Scheduler {
- private final ExecutorService exec;
- private final Queue> tasks;
- private final AtomicBoolean busy;
+ /**
+ *
+ * Basic lock-free scheduler that requires submitted tasks to signal their completion explicitly (and possibly
+ * asynchronously). Only after the current task has signaled its completion will the next task be started.
+ *
+ *
+ *
+ * Each task is represented as a pair that consists of: (1) a closure that represents the work of the task, with a
+ * formal parameter of type {@link CompletableFuture}, and (2) a future that represents the result of the task, which is
+ * passed to the closure as actual parameter when the task is started. The body of the closure must eventually call
+ * {@link CompletableFuture#complete} (or any other {@code complete...} method) on the future to signal its completion
+ * and provide the result. Not performing such a call causes the scheduler to get stuck.
+ *
+ *
+ *
+ * When a new task is submitted, and if no existing task is in progress yet, then the new task is started immediately.
+ * In contrast, if an existing task is in progress already, then the new task is started when the current task and all
+ * other pending existing tasks in the queue have signaled their completion. To this end, attempts to start tasks are
+ * made in two places: in the method to submit a new task, and in the closure that is run when an existing task has
+ * signaled its completion.
+ *
+ *
+ * @param R Result type of tasks
+ */
+ private class Scheduler {
+ private final Queue tasks = new ConcurrentLinkedQueue<>();
+ private final AtomicBoolean busy = new AtomicBoolean(false);
+
+ public CompletableFuture submit(Consumer> action) {
+ var result = new CompletableFuture();
+ var task = new Task(action, result);
+ tasks.offer(task);
+ exec.submit(this::attemptStartTask);
+ return result;
+ }
- public Scheduler(ExecutorService exec) {
- this.exec = exec;
- this.busy = new AtomicBoolean(false);
- this.tasks = new ConcurrentLinkedQueue<>();
- }
-
- public CompletableFuture submit(Consumer> action) {
- var result = new CompletableFuture();
- var task = new Task<>(action, result);
- tasks.offer(task);
- exec.submit(this::attemptStartTask);
- return result;
- }
-
- private void attemptStartTask() {
- if (busy.compareAndSet(false, true)) {
- var task = tasks.poll();
- if (task != null) {
- // Install a finally-block-like closure that is run when the current task has signaled its completion.
- // This is to ensure that the next task is subsequently started (if any). Note: Result `v` and exception
- // `t` are ignored; it is the responsibility of other calls on `task.result` to handle them.
- task.result.whenComplete((v, t) -> {
+ private void attemptStartTask() {
+ if (busy.compareAndSet(false, true)) {
+ var task = tasks.poll();
+ if (task != null) {
+ // Install a finally-block-like closure that is run when the current task has signaled its completion.
+ // This is to ensure that the next task is subsequently started (if any). Note: Result `v` and exception
+ // `t` are ignored; it is the responsibility of other calls on `task.result` to handle them.
+ task.result.whenComplete((v, t) -> {
+ busy.set(false);
+ exec.submit(this::attemptStartTask);
+ });
+ task.action.accept(task.result);
+ // Don't unset `busy` yet. Instead, doing so is the responsibility of the closure on the previous lines
+ // and should happen only when the task has signaled its completion.
+ } else {
busy.set(false);
- exec.submit(this::attemptStartTask);
- });
- task.action.accept(task.result);
- // Don't unset `busy` yet. Instead, doing so is the responsibility of the closure on the previous lines
- // and should happen only when the task has signaled its completion.
- } else {
- busy.set(false);
+ }
}
}
- }
- private static class Task {
- private final Consumer> action;
- private final CompletableFuture result;
+ private class Task {
+ private final Consumer> action;
+ private final CompletableFuture result;
- public Task(Consumer> action, CompletableFuture result) {
- this.action = action;
- this.result = result;
+ public Task(Consumer> action, CompletableFuture result) {
+ this.action = action;
+ this.result = result;
+ }
}
}
-}
-/**
- * Utility methods to perform operations on maps of maps
- */
-class MapOfMaps {
- private MapOfMaps() {}
+ /**
+ * Utility methods to perform operations on maps of maps
+ */
+ private static class MapOfMaps {
+ private MapOfMaps() {}
- public static @Nullable V get(Map> mapOfMaps, K1 key1, K2 key2) {
- return mapOfMaps
- .getOrDefault(key1, Collections.emptyMap())
- .get(key2);
- }
+ public static @Nullable V get(Map> mapOfMaps, K1 key1, K2 key2) {
+ return mapOfMaps
+ .getOrDefault(key1, Collections.emptyMap())
+ .get(key2);
+ }
- public static @Nullable V put(Map> mapOfMaps, K1 key1, K2 key2, V value) {
- return mapOfMaps
- .computeIfAbsent(key1, m -> new ConcurrentHashMap<>())
- .put(key2, value);
- }
+ public static @Nullable V put(Map> mapOfMaps, K1 key1, K2 key2, V value) {
+ return mapOfMaps
+ .computeIfAbsent(key1, m -> new ConcurrentHashMap<>())
+ .put(key2, value);
+ }
- public static @Nullable V remove(Map> mapOfMaps, K1 key1, K2 key2) {
- // Default needs to be mutable (support `remove` calls) so `Collections.emptyMap()` cannot be used
- var map = mapOfMaps.getOrDefault(key1, new HashMap<>());
- var removed = map.remove(key2);
- if (map.isEmpty()) {
- mapOfMaps.remove(key1);
+ public static @Nullable V remove(Map> mapOfMaps, K1 key1, K2 key2) {
+ // Default needs to be mutable (support `remove` calls) so `Collections.emptyMap()` cannot be used
+ var map = mapOfMaps.getOrDefault(key1, new HashMap<>());
+ var removed = map.remove(key2);
+ if (map.isEmpty()) {
+ mapOfMaps.remove(key1);
+ }
+ return removed;
}
- return removed;
}
-}
-/**
- * Utility methods to perform operations on maps of maps of sets
- */
-class MapOfMapsOfSets {
- private MapOfMapsOfSets() {}
-
- public static boolean add(Map>> mapOfMapOfSets, K1 key1, K2 key2, V value) {
- return mapOfMapOfSets
- .computeIfAbsent(key1, m -> new ConcurrentHashMap<>())
- .computeIfAbsent(key2, o -> ConcurrentHashMap.newKeySet())
- .add(value);
- }
-
- public static @Nullable V findAny(Map>> mapOfMapOfSets, Predicate predicate) {
- return mapOfMapOfSets
- .values()
- .stream() // Stream of maps of sets of values
- .map(Map::values) // Stream of collections of sets of values
- .flatMap(Collection::stream) // Stream of sets of values
- .flatMap(Collection::stream) // Stream of values
- .filter(predicate)
- .findAny()
- .orElse(null);
- }
-
- public static boolean remove(Map>> mapOfMapOfSets, K1 key1, K2 key2, V value) {
- // Defaults need to be mutable (support `remove` calls) so `Collections.empty...()` cannot be used
- var mapOfSets = mapOfMapOfSets.getOrDefault(key1, new HashMap<>());
- var set = mapOfSets.getOrDefault(key2, new HashSet<>());
- var removed = false;
- if (value != null) { // Convince Checker Framework
- removed = set.remove(value);
+ /**
+ * Utility methods to perform operations on maps of maps of sets
+ */
+ private static class MapOfMapsOfSets {
+ private MapOfMapsOfSets() {}
+
+ public static boolean add(Map>> mapOfMapOfSets, K1 key1, K2 key2, V value) {
+ return mapOfMapOfSets
+ .computeIfAbsent(key1, m -> new ConcurrentHashMap<>())
+ .computeIfAbsent(key2, o -> ConcurrentHashMap.newKeySet())
+ .add(value);
}
- if (set.isEmpty()) {
- mapOfSets.remove(key2);
+
+ public static @Nullable V findAny(Map>> mapOfMapOfSets, Predicate predicate) {
+ return mapOfMapOfSets
+ .values()
+ .stream() // Stream of maps of sets of values
+ .map(Map::values) // Stream of collections of sets of values
+ .flatMap(Collection::stream) // Stream of sets of values
+ .flatMap(Collection::stream) // Stream of values
+ .filter(predicate)
+ .findAny()
+ .orElse(null);
}
- if (mapOfSets.isEmpty()) {
- mapOfMapOfSets.remove(key1);
+
+ public static boolean remove(Map>> mapOfMapOfSets, K1 key1, K2 key2, V value) {
+ // Defaults need to be mutable (support `remove` calls) so `Collections.empty...()` cannot be used
+ var mapOfSets = mapOfMapOfSets.getOrDefault(key1, new HashMap<>());
+ var set = mapOfSets.getOrDefault(key2, new HashSet<>());
+ var removed = false;
+ if (value != null) { // Convince Checker Framework
+ removed = set.remove(value);
+ }
+ if (set.isEmpty()) {
+ mapOfSets.remove(key2);
+ }
+ if (mapOfSets.isEmpty()) {
+ mapOfMapOfSets.remove(key1);
+ }
+ return removed;
}
- return removed;
- }
- public static int size(Map>> mapOfMapOfSets, K1 key1, K2 key2) {
- return mapOfMapOfSets
- .getOrDefault(key1, Collections.emptyMap())
- .getOrDefault(key2, Collections.emptySet())
- .size();
+ public static int size(Map>> mapOfMapOfSets, K1 key1, K2 key2) {
+ return mapOfMapOfSets
+ .getOrDefault(key1, Collections.emptyMap())
+ .getOrDefault(key2, Collections.emptySet())
+ .size();
+ }
}
}
From ffdc39687213ab8dd62df20ff1b436ca4c803ea7 Mon Sep 17 00:00:00 2001
From: Sung-Shik Jongmans
Date: Tue, 22 Sep 2026 14:18:52 +0200
Subject: [PATCH 24/32] Improve comments
---
.../vscode/lsp/parametric/routing/MultipleClientProxy.java | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/routing/MultipleClientProxy.java b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/routing/MultipleClientProxy.java
index 236815323..aede1db87 100644
--- a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/routing/MultipleClientProxy.java
+++ b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/routing/MultipleClientProxy.java
@@ -504,6 +504,12 @@ private void attemptStartTask() {
// Don't unset `busy` yet. Instead, doing so is the responsibility of the closure on the previous lines
// and should happen only when the task has signaled its completion.
} else {
+ // In this case (presumably rare), the current `attemptStartTask` call was submitted after a new
+ // task was offered into the queue, but the by the time the call begins, the queue has already
+ // become empty. This can happen when, between the offer and the submission, *a previous*
+ // `attemptStartTask` call (which ran concurrently) recursively submitted *a next*
+ // `attemptStartTask` call as part of the `whenComplete` closure, which polled the new task out of
+ // the queue before *the current* `attemptStartTask` call gets the opportunity to do so.
busy.set(false);
}
}
From 715f35aadf1c5bc2259cb1f959b9a1287b20bce7 Mon Sep 17 00:00:00 2001
From: Sung-Shik Jongmans
Date: Tue, 22 Sep 2026 15:57:32 +0200
Subject: [PATCH 25/32] Refactor `Scheduler` by streamlining creation
management of futures
---
.../routing/MultipleClientProxy.java | 108 +++++++++---------
1 file changed, 54 insertions(+), 54 deletions(-)
diff --git a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/routing/MultipleClientProxy.java b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/routing/MultipleClientProxy.java
index aede1db87..3f7a90dc3 100644
--- a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/routing/MultipleClientProxy.java
+++ b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/routing/MultipleClientProxy.java
@@ -43,9 +43,11 @@
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.atomic.AtomicBoolean;
-import java.util.function.Consumer;
+import java.util.function.BiFunction;
import java.util.function.Function;
import java.util.function.Predicate;
+import java.util.function.Supplier;
+
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.checkerframework.checker.nullness.qual.NonNull;
@@ -311,14 +313,15 @@ public void sourceLocationChanged(ISourceLocationChanged changed) {
*
*
* To coordinate calls of {@code registerCapability} and {@code unregisterCapability} and avoid races, instances of
- * this class internally use a basic lock-free scheduler. Essentially, the scheduler ensures that the work of each
- * next call of {@code registerCapability} or {@code unregisterCapability} will begin only when the work of the
- * current call, including its asyncronous completion, has ended. See the JavaDoc of {@link Scheduler} for
- * details.
+ * this class internally use a lock-free sequential scheduler. Essentially, the scheduler ensures that the work of
+ * each next call of {@code registerCapability} or {@code unregisterCapability} will begin only when the work of the
+ * current call, including its asyncronous completion, has ended. See the JavaDoc of {@link SequentialScheduler} for
+ * details. A custom scheduler is used, instead of {@link java.util.concurrent.Executors#newSingleThreadExecutor()},
+ * because a lock-free mechanism is needed to await asyncronous completion of calls (tasks).
*
*/
private class CapabilityRegistry {
- private final Scheduler scheduler = new Scheduler<>();
+ private final SequentialScheduler scheduler = new SequentialScheduler<>();
private final Map>> sentByServers = new ConcurrentHashMap<>();
private final Map> receivedByClient = new ConcurrentHashMap<>();
// Notes:
@@ -337,37 +340,33 @@ public CompletableFuture registerCapability(Registration fromServer) {
var id = fromServer.getId();
logger.trace("Register capability {} ({}): Submitting to scheduler...", method, id);
- return scheduler.submit(result -> {
+ return scheduler.submit(() -> {
var options = fromServer.getRegisterOptions();
var remaining = MapOfMapsOfSets.size(sentByServers, method, options);
// Case: Must forward registration
if (remaining == 0) {
logger.trace("Register capability {} ({}): Forwarding registration to client...", method, id);
- forwardRegistration(method, options).whenCompleteAsync((toClient, thrown) -> {
+ return forwardRegistration(method, options).handleAsync((BiFunction) (toClient, ex) -> {
// Case: Forwarding succeeded
- if (thrown == null) {
+ if (ex == null) {
logger.trace("Register capability {} ({}): Forwarded registration to client. Succeeded.", method, id);
MapOfMaps.put(receivedByClient, method, options, toClient);
MapOfMapsOfSets.add(sentByServers, method, options, fromServer);
- result.complete(null);
}
// Case: Forwarding failed
else {
- logger.trace("Register capability {} ({}): Forwarded registration to client. Failed: {}", method, id, thrown);
- result.completeExceptionally(thrown);
+ logger.trace("Register capability {} ({}): Forwarded registration to client. Failed: {}", method, id, ex);
}
+ return null; // Void
}, exec);
- // Don't complete `result` yet. Instead, doing so is the responsibility of the closure on the
- // previous lines and should happen only when it is known if the registration succeeded or failed at
- // the client (which isn't immediately after `whenCompleteAsync` returns, but asynchronously).
}
// Case: Must not forward
else {
logger.trace("Register capability {} ({}): Not forwarding registration to client, because >0 other registrations remain for same capability (remaining: {})", method, id, remaining);
MapOfMapsOfSets.add(sentByServers, method, options, fromServer);
- result.complete(null);
+ return CompletableFuture.completedFuture(null);
}
});
}
@@ -383,15 +382,14 @@ public CompletableFuture unregisterCapability(Unregistration u) {
var id = u.getId();
logger.trace("Unregister capability {} ({}): Submitting to scheduler...", method, id);
- return scheduler.submit(result -> {
+ return scheduler.submit(() -> {
// Find the corresponding registration previously sent by a server
var fromServer = MapOfMapsOfSets.findAny(sentByServers, r -> matches(r, u));
if (fromServer == null) {
- var t = new IllegalStateException("Cannot unregister a capability for which no registration was sent by a server");
- logger.trace("Unregister capability {} ({}). Failed: {}", method, id, t);
- result.completeExceptionally(t);
- return;
+ var ex = new IllegalStateException("Cannot unregister a capability for which no registration was sent by a server");
+ logger.trace("Unregister capability {} ({}). Failed: {}", method, id, ex);
+ return CompletableFuture.failedFuture(ex);
}
var options = fromServer.getRegisterOptions();
@@ -408,36 +406,31 @@ public CompletableFuture unregisterCapability(Unregistration u) {
// invariant that the number of registrations of a capability in `sentByServers` is greater than
// 0 if, and only if, there is a registration of that capability in `receivedByClient`. So, if
// `remaining == 1`, but `toClient == null`, then the invariant is broken.
- var t = new IllegalStateException("Cannot unregister a capability for which no registration was received by the client");
- logger.trace("Unregister capability {} ({}). Failed: {}", method, id, t);
- result.completeExceptionally(t);
- return;
+ var ex = new IllegalStateException("Cannot unregister a capability for which no registration was received by the client");
+ logger.trace("Unregister capability {} ({}). Failed: {}", method, id, ex);
+ return CompletableFuture.failedFuture(ex);
}
- forwardUnregistration(toClient.getId(), method).whenCompleteAsync((_u, thrown) -> {
+ return forwardUnregistration(toClient.getId(), method).handleAsync((BiFunction) (_u, ex) -> {
// Case: Forwarding succeeded
- if (thrown == null) {
+ if (ex == null) {
logger.trace("Unregister capability {} ({}): Forwarded unregistration to client. Succeeded.", method, id);
MapOfMaps.remove(receivedByClient, method, options);
MapOfMapsOfSets.remove(sentByServers, method, options, fromServer);
- result.complete(null);
}
// Case: Forwarding failed
else {
- logger.trace("Unregister capability {} ({}): Forwarded unregistration to client. Failed: {}", method, id, thrown);
- result.completeExceptionally(thrown);
+ logger.trace("Unregister capability {} ({}): Forwarded unregistration to client. Failed: {}", method, id, ex);
}
+ return null; // Void
}, exec);
- // Don't complete `result` yet. Instead, doing so is the responsibility of the closure on the
- // previous lines and should happen only when it is known if the unregistration succeeded or failed
- // at the client (which isn't immediately after `whenCompleteAsync` returns, but asynchronously).
}
// Case: Must not forward unregistration
else {
logger.trace("Unregister capability {} ({}): Not forwarding unregistration to client, as 0 or >1 other registrations remain for same capability (remaining: {})", method, id, remaining);
MapOfMapsOfSets.remove(sentByServers, method, options, fromServer);
- result.complete(null);
+ return CompletableFuture.completedFuture(null);
}
});
}
@@ -455,16 +448,19 @@ private CompletableFuture forwardUnregistration(String id, Strin
/**
*
- * Basic lock-free scheduler that requires submitted tasks to signal their completion explicitly (and possibly
+ * Lock-free sequential scheduler that requires submitted tasks to signal their completion explicitly (and possibly
* asynchronously). Only after the current task has signaled its completion will the next task be started.
*
*
*
- * Each task is represented as a pair that consists of: (1) a closure that represents the work of the task, with a
- * formal parameter of type {@link CompletableFuture}, and (2) a future that represents the result of the task, which is
- * passed to the closure as actual parameter when the task is started. The body of the closure must eventually call
- * {@link CompletableFuture#complete} (or any other {@code complete...} method) on the future to signal its completion
- * and provide the result. Not performing such a call causes the scheduler to get stuck.
+ * Each task is represented as a pair that consists of: (1) a closure that represents the work and returns an
+ * *internal* future that represents the result for the scheduler, and (2) an *external* future that represents the
+ * result for the submitter. The body of the closure must eventually call {@link CompletableFuture#complete} (or any
+ * other {@code complete...} method) on the internal future to signal its completion and supply the result. Not
+ * completing an internal future causes the scheduler to get stuck. When the internal future is complete, the
+ * scheduler completes the external future by propagating the result. This two-stage approach is needed because the
+ * internal future isn't available yet when the external future needs to be returned to the submitter (i.e., the
+ * internal future is created as part of running the closure, but this happens only when the task has been started).
*
*
*
@@ -477,14 +473,13 @@ private CompletableFuture forwardUnregistration(String id, Strin
*
* @param R Result type of tasks
*/
- private class Scheduler {
+ private class SequentialScheduler {
private final Queue tasks = new ConcurrentLinkedQueue<>();
private final AtomicBoolean busy = new AtomicBoolean(false);
- public CompletableFuture submit(Consumer> action) {
+ public CompletableFuture submit(Supplier> action) {
var result = new CompletableFuture();
- var task = new Task(action, result);
- tasks.offer(task);
+ tasks.offer(new Task(action, result));
exec.submit(this::attemptStartTask);
return result;
}
@@ -493,14 +488,19 @@ private void attemptStartTask() {
if (busy.compareAndSet(false, true)) {
var task = tasks.poll();
if (task != null) {
- // Install a finally-block-like closure that is run when the current task has signaled its completion.
- // This is to ensure that the next task is subsequently started (if any). Note: Result `v` and exception
- // `t` are ignored; it is the responsibility of other calls on `task.result` to handle them.
- task.result.whenComplete((v, t) -> {
+ var internalFuture = task.action.get(); // Start task
+ var externalFuture = task.result;
+ internalFuture.whenCompleteAsync((value, ex) -> { // Propagate result
+ if (ex == null) {
+ externalFuture.complete(value);
+ } else {
+ externalFuture.completeExceptionally(ex);
+ }
+ }, exec);
+ internalFuture.whenCompleteAsync((value, ex) -> { // Start next task (if any)
busy.set(false);
exec.submit(this::attemptStartTask);
- });
- task.action.accept(task.result);
+ }, exec);
// Don't unset `busy` yet. Instead, doing so is the responsibility of the closure on the previous lines
// and should happen only when the task has signaled its completion.
} else {
@@ -508,18 +508,18 @@ private void attemptStartTask() {
// task was offered into the queue, but the by the time the call begins, the queue has already
// become empty. This can happen when, between the offer and the submission, *a previous*
// `attemptStartTask` call (which ran concurrently) recursively submitted *a next*
- // `attemptStartTask` call as part of the `whenComplete` closure, which polled the new task out of
- // the queue before *the current* `attemptStartTask` call gets the opportunity to do so.
+ // `attemptStartTask` call as part of the `whenComplete` closure above, which polled the new task
+ // out of the queue before *the current* `attemptStartTask` call gets the opportunity to do so.
busy.set(false);
}
}
}
private class Task {
- private final Consumer> action;
+ private final Supplier> action;
private final CompletableFuture result;
- public Task(Consumer> action, CompletableFuture result) {
+ public Task(Supplier> action, CompletableFuture result) {
this.action = action;
this.result = result;
}
From 61700c553f9aadfa9c183b52cbd1066b3e58c093 Mon Sep 17 00:00:00 2001
From: Sung-Shik Jongmans
Date: Wed, 23 Sep 2026 09:25:04 +0200
Subject: [PATCH 26/32] Add utility method to create `CompletableFuture`
instances with custom default executor
---
.../lsp/parametric/routing/MultipleClientProxy.java | 2 +-
.../lsp/util/concurrent/CompletableFutureUtils.java | 9 +++++++++
2 files changed, 10 insertions(+), 1 deletion(-)
diff --git a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/routing/MultipleClientProxy.java b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/routing/MultipleClientProxy.java
index 3f7a90dc3..1a9056445 100644
--- a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/routing/MultipleClientProxy.java
+++ b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/routing/MultipleClientProxy.java
@@ -478,7 +478,7 @@ private class SequentialScheduler {
private final AtomicBoolean busy = new AtomicBoolean(false);
public CompletableFuture submit(Supplier> action) {
- var result = new CompletableFuture();
+ var result = CompletableFutureUtils. create(exec);
tasks.offer(new Task(action, result));
exec.submit(this::attemptStartTask);
return result;
diff --git a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/util/concurrent/CompletableFutureUtils.java b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/util/concurrent/CompletableFutureUtils.java
index 53fd7d27a..5be97b188 100644
--- a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/util/concurrent/CompletableFutureUtils.java
+++ b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/util/concurrent/CompletableFutureUtils.java
@@ -47,6 +47,15 @@ private CompletableFutureUtils() {/* hidden */ }
public static final CompletableFuture NOOP = CompletableFuture.completedFuture(null);
private static final Logger logger = LogManager.getLogger(CompletableFutureUtils.class);
+ public static CompletableFuture create(Executor exec) {
+ return new CompletableFuture<>() {
+ @Override
+ public Executor defaultExecutor() {
+ return exec;
+ }
+ };
+ }
+
public static CompletableFuture completedFuture(T value, Executor exec) {
return CompletableFuture.supplyAsync(() -> value, exec);
}
From 67681d070fdd964aa6d49a4cb5767a4c461a9ee9 Mon Sep 17 00:00:00 2001
From: Sung-Shik Jongmans
Date: Wed, 23 Sep 2026 09:40:40 +0200
Subject: [PATCH 27/32] Add utility method to create a failed
`CompletableFuture` with custom default executor
---
.../lsp/parametric/routing/MultipleClientProxy.java | 8 ++++----
.../lsp/util/concurrent/CompletableFutureUtils.java | 6 ++++++
2 files changed, 10 insertions(+), 4 deletions(-)
diff --git a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/routing/MultipleClientProxy.java b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/routing/MultipleClientProxy.java
index 1a9056445..e51053c8f 100644
--- a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/routing/MultipleClientProxy.java
+++ b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/routing/MultipleClientProxy.java
@@ -366,7 +366,7 @@ public CompletableFuture registerCapability(Registration fromServer) {
else {
logger.trace("Register capability {} ({}): Not forwarding registration to client, because >0 other registrations remain for same capability (remaining: {})", method, id, remaining);
MapOfMapsOfSets.add(sentByServers, method, options, fromServer);
- return CompletableFuture.completedFuture(null);
+ return CompletableFutureUtils.completedFuture(null, exec);
}
});
}
@@ -389,7 +389,7 @@ public CompletableFuture unregisterCapability(Unregistration u) {
if (fromServer == null) {
var ex = new IllegalStateException("Cannot unregister a capability for which no registration was sent by a server");
logger.trace("Unregister capability {} ({}). Failed: {}", method, id, ex);
- return CompletableFuture.failedFuture(ex);
+ return CompletableFutureUtils.failedFuture(ex, exec);
}
var options = fromServer.getRegisterOptions();
@@ -408,7 +408,7 @@ public CompletableFuture unregisterCapability(Unregistration u) {
// `remaining == 1`, but `toClient == null`, then the invariant is broken.
var ex = new IllegalStateException("Cannot unregister a capability for which no registration was received by the client");
logger.trace("Unregister capability {} ({}). Failed: {}", method, id, ex);
- return CompletableFuture.failedFuture(ex);
+ return CompletableFutureUtils.failedFuture(ex, exec);
}
return forwardUnregistration(toClient.getId(), method).handleAsync((BiFunction) (_u, ex) -> {
@@ -430,7 +430,7 @@ public CompletableFuture unregisterCapability(Unregistration u) {
else {
logger.trace("Unregister capability {} ({}): Not forwarding unregistration to client, as 0 or >1 other registrations remain for same capability (remaining: {})", method, id, remaining);
MapOfMapsOfSets.remove(sentByServers, method, options, fromServer);
- return CompletableFuture.completedFuture(null);
+ return CompletableFutureUtils.completedFuture(null, exec);
}
});
}
diff --git a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/util/concurrent/CompletableFutureUtils.java b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/util/concurrent/CompletableFutureUtils.java
index 5be97b188..c12097fdb 100644
--- a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/util/concurrent/CompletableFutureUtils.java
+++ b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/util/concurrent/CompletableFutureUtils.java
@@ -60,6 +60,12 @@ public static CompletableFuture completedFuture(T value, Executor exec) {
return CompletableFuture.supplyAsync(() -> value, exec);
}
+ public static CompletableFuture failedFuture(Throwable ex, Executor exec) {
+ var f = CompletableFutureUtils. create(exec);
+ f.completeExceptionally(ex);
+ return f;
+ }
+
public static CompletableFuture retry(Supplier supplier, int times, Executor exec) {
return retry(CompletableFuture.supplyAsync(supplier, exec), times);
}
From 0e0a1e6f5fb2e4b966736b1be262f3117443dfa5 Mon Sep 17 00:00:00 2001
From: Sung-Shik Jongmans
Date: Wed, 23 Sep 2026 09:41:23 +0200
Subject: [PATCH 28/32] Improve comments
---
.../routing/MultipleClientProxy.java | 31 +++++++++----------
1 file changed, 15 insertions(+), 16 deletions(-)
diff --git a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/routing/MultipleClientProxy.java b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/routing/MultipleClientProxy.java
index e51053c8f..f334f7e7b 100644
--- a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/routing/MultipleClientProxy.java
+++ b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/routing/MultipleClientProxy.java
@@ -43,7 +43,6 @@
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.atomic.AtomicBoolean;
-import java.util.function.BiFunction;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.function.Supplier;
@@ -347,7 +346,7 @@ public CompletableFuture registerCapability(Registration fromServer) {
// Case: Must forward registration
if (remaining == 0) {
logger.trace("Register capability {} ({}): Forwarding registration to client...", method, id);
- return forwardRegistration(method, options).handleAsync((BiFunction) (toClient, ex) -> {
+ return forwardRegistration(method, options).handleAsync((toClient, ex) -> {
// Case: Forwarding succeeded
if (ex == null) {
logger.trace("Register capability {} ({}): Forwarded registration to client. Succeeded.", method, id);
@@ -403,15 +402,15 @@ public CompletableFuture unregisterCapability(Unregistration u) {
var toClient = MapOfMaps.get(receivedByClient, method, options);
if (toClient == null) {
// This should never happen: instances of this class are intended to preserve the consistency
- // invariant that the number of registrations of a capability in `sentByServers` is greater than
- // 0 if, and only if, there is a registration of that capability in `receivedByClient`. So, if
- // `remaining == 1`, but `toClient == null`, then the invariant is broken.
+ // invariant that "the number of registrations of a capability in `sentByServers` is >0" if and
+ // only if "there is a registration of that capability in `receivedByClient`". So, if `remaining
+ // == 1`, but `toClient == null`, then the invariant is accidentally broken.
var ex = new IllegalStateException("Cannot unregister a capability for which no registration was received by the client");
logger.trace("Unregister capability {} ({}). Failed: {}", method, id, ex);
return CompletableFutureUtils.failedFuture(ex, exec);
}
- return forwardUnregistration(toClient.getId(), method).handleAsync((BiFunction) (_u, ex) -> {
+ return forwardUnregistration(toClient.getId(), method).handleAsync((_u, ex) -> {
// Case: Forwarding succeeded
if (ex == null) {
logger.trace("Unregister capability {} ({}): Forwarded unregistration to client. Succeeded.", method, id);
@@ -428,7 +427,7 @@ public CompletableFuture unregisterCapability(Unregistration u) {
// Case: Must not forward unregistration
else {
- logger.trace("Unregister capability {} ({}): Not forwarding unregistration to client, as 0 or >1 other registrations remain for same capability (remaining: {})", method, id, remaining);
+ logger.trace("Unregister capability {} ({}): Not forwarding unregistration to client, because 0 or >1 other registrations remain for same capability (remaining: {})", method, id, remaining);
MapOfMapsOfSets.remove(sentByServers, method, options, fromServer);
return CompletableFutureUtils.completedFuture(null, exec);
}
@@ -453,14 +452,14 @@ private CompletableFuture forwardUnregistration(String id, Strin
*
*
*
- * Each task is represented as a pair that consists of: (1) a closure that represents the work and returns an
- * *internal* future that represents the result for the scheduler, and (2) an *external* future that represents the
- * result for the submitter. The body of the closure must eventually call {@link CompletableFuture#complete} (or any
- * other {@code complete...} method) on the internal future to signal its completion and supply the result. Not
- * completing an internal future causes the scheduler to get stuck. When the internal future is complete, the
- * scheduler completes the external future by propagating the result. This two-stage approach is needed because the
- * internal future isn't available yet when the external future needs to be returned to the submitter (i.e., the
- * internal future is created as part of running the closure, but this happens only when the task has been started).
+ * Each task is a pair that consists of: (1) a closure that defines the work and returns an *internal* future that
+ * represents the result for the scheduler, and (2) an *external* future that represents the result for the
+ * submitter. The body of the closure must eventually call {@link CompletableFuture#complete} (or any other
+ * {@code complete...} method) on the internal future to signal its completion and supply the result. Not completing
+ * an internal future causes the scheduler to get stuck. When the internal future is complete, the scheduler
+ * completes the external future by propagating the result. This two-stage approach is needed because the internal
+ * future isn't available yet when the external future needs to be returned to the submitter (i.e., the internal
+ * future is created as part of running the closure, but this happens only when the task has been started).
*
*
*
@@ -497,7 +496,7 @@ private void attemptStartTask() {
externalFuture.completeExceptionally(ex);
}
}, exec);
- internalFuture.whenCompleteAsync((value, ex) -> { // Start next task (if any)
+ internalFuture.whenCompleteAsync((value, ex) -> { // Attemp to start next task (if any)
busy.set(false);
exec.submit(this::attemptStartTask);
}, exec);
From 1dff4b51f03b7736cdb073b7f6e384545349fef1 Mon Sep 17 00:00:00 2001
From: Sung-Shik Jongmans
Date: Wed, 23 Sep 2026 09:52:53 +0200
Subject: [PATCH 29/32] Use `whenCompleteAsync` instead of `handle` to preserve
failure state
---
.../parametric/routing/MultipleClientProxy.java | 14 ++++++--------
1 file changed, 6 insertions(+), 8 deletions(-)
diff --git a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/routing/MultipleClientProxy.java b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/routing/MultipleClientProxy.java
index f334f7e7b..c0f5ce2ac 100644
--- a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/routing/MultipleClientProxy.java
+++ b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/routing/MultipleClientProxy.java
@@ -346,7 +346,7 @@ public CompletableFuture registerCapability(Registration fromServer) {
// Case: Must forward registration
if (remaining == 0) {
logger.trace("Register capability {} ({}): Forwarding registration to client...", method, id);
- return forwardRegistration(method, options).handleAsync((toClient, ex) -> {
+ return forwardRegistration(method, options).whenCompleteAsync((toClient, ex) -> {
// Case: Forwarding succeeded
if (ex == null) {
logger.trace("Register capability {} ({}): Forwarded registration to client. Succeeded.", method, id);
@@ -357,15 +357,14 @@ public CompletableFuture registerCapability(Registration fromServer) {
else {
logger.trace("Register capability {} ({}): Forwarded registration to client. Failed: {}", method, id, ex);
}
- return null; // Void
- }, exec);
+ }, exec).thenCompose(_toClient -> null /* Void */);
}
// Case: Must not forward
else {
logger.trace("Register capability {} ({}): Not forwarding registration to client, because >0 other registrations remain for same capability (remaining: {})", method, id, remaining);
MapOfMapsOfSets.add(sentByServers, method, options, fromServer);
- return CompletableFutureUtils.completedFuture(null, exec);
+ return CompletableFutureUtils.completedFuture(null /* Void */, exec);
}
});
}
@@ -410,7 +409,7 @@ public CompletableFuture unregisterCapability(Unregistration u) {
return CompletableFutureUtils.failedFuture(ex, exec);
}
- return forwardUnregistration(toClient.getId(), method).handleAsync((_u, ex) -> {
+ return forwardUnregistration(toClient.getId(), method).whenCompleteAsync((_u, ex) -> {
// Case: Forwarding succeeded
if (ex == null) {
logger.trace("Unregister capability {} ({}): Forwarded unregistration to client. Succeeded.", method, id);
@@ -421,15 +420,14 @@ public CompletableFuture unregisterCapability(Unregistration u) {
else {
logger.trace("Unregister capability {} ({}): Forwarded unregistration to client. Failed: {}", method, id, ex);
}
- return null; // Void
- }, exec);
+ }, exec).thenCompose(_u -> null /* Void */);
}
// Case: Must not forward unregistration
else {
logger.trace("Unregister capability {} ({}): Not forwarding unregistration to client, because 0 or >1 other registrations remain for same capability (remaining: {})", method, id, remaining);
MapOfMapsOfSets.remove(sentByServers, method, options, fromServer);
- return CompletableFutureUtils.completedFuture(null, exec);
+ return CompletableFutureUtils.completedFuture(null /* Void */, exec);
}
});
}
From ad6e6aca5b7022623d9cbf525d0b64076befeae4 Mon Sep 17 00:00:00 2001
From: Sung-Shik Jongmans
Date: Wed, 23 Sep 2026 10:04:55 +0200
Subject: [PATCH 30/32] Fix issue that `thenApply` should have been used
instead of `thenCompose`
---
.../vscode/lsp/parametric/routing/MultipleClientProxy.java | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/routing/MultipleClientProxy.java b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/routing/MultipleClientProxy.java
index c0f5ce2ac..8c8ae9719 100644
--- a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/routing/MultipleClientProxy.java
+++ b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/routing/MultipleClientProxy.java
@@ -357,7 +357,7 @@ public CompletableFuture registerCapability(Registration fromServer) {
else {
logger.trace("Register capability {} ({}): Forwarded registration to client. Failed: {}", method, id, ex);
}
- }, exec).thenCompose(_toClient -> null /* Void */);
+ }, exec).thenApply(_toClient -> null /* Void */);
}
// Case: Must not forward
@@ -420,7 +420,7 @@ public CompletableFuture unregisterCapability(Unregistration u) {
else {
logger.trace("Unregister capability {} ({}): Forwarded unregistration to client. Failed: {}", method, id, ex);
}
- }, exec).thenCompose(_u -> null /* Void */);
+ }, exec).thenApply(_u -> null /* Void */);
}
// Case: Must not forward unregistration
From bbe374497463bf2b61af1f4f7e07a8d61569b928 Mon Sep 17 00:00:00 2001
From: Sung-Shik Jongmans
Date: Wed, 23 Sep 2026 10:20:43 +0200
Subject: [PATCH 31/32] Refactor `completedFuture` to the same style as
`failedFuture`
---
.../vscode/lsp/util/concurrent/CompletableFutureUtils.java | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/util/concurrent/CompletableFutureUtils.java b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/util/concurrent/CompletableFutureUtils.java
index c12097fdb..dafaecf75 100644
--- a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/util/concurrent/CompletableFutureUtils.java
+++ b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/util/concurrent/CompletableFutureUtils.java
@@ -57,7 +57,9 @@ public Executor defaultExecutor() {
}
public static CompletableFuture completedFuture(T value, Executor exec) {
- return CompletableFuture.supplyAsync(() -> value, exec);
+ var f = CompletableFutureUtils. create(exec);
+ f.complete(value);
+ return f;
}
public static CompletableFuture failedFuture(Throwable ex, Executor exec) {
From 05719bfbcb6c86ed3b3f4d8205d14fadf976f539 Mon Sep 17 00:00:00 2001
From: Sung-Shik Jongmans
Date: Wed, 23 Sep 2026 10:25:55 +0200
Subject: [PATCH 32/32] Report errors at ERROR log level instead of TRACE
---
.../lsp/parametric/routing/MultipleClientProxy.java | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/routing/MultipleClientProxy.java b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/routing/MultipleClientProxy.java
index 8c8ae9719..8d84993b2 100644
--- a/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/routing/MultipleClientProxy.java
+++ b/rascal-lsp/src/main/java/org/rascalmpl/vscode/lsp/parametric/routing/MultipleClientProxy.java
@@ -355,7 +355,7 @@ public CompletableFuture registerCapability(Registration fromServer) {
}
// Case: Forwarding failed
else {
- logger.trace("Register capability {} ({}): Forwarded registration to client. Failed: {}", method, id, ex);
+ logger.error("Register capability {} ({}): Forwarded registration to client. Failed: {}", method, id, ex);
}
}, exec).thenApply(_toClient -> null /* Void */);
}
@@ -386,7 +386,7 @@ public CompletableFuture unregisterCapability(Unregistration u) {
var fromServer = MapOfMapsOfSets.findAny(sentByServers, r -> matches(r, u));
if (fromServer == null) {
var ex = new IllegalStateException("Cannot unregister a capability for which no registration was sent by a server");
- logger.trace("Unregister capability {} ({}). Failed: {}", method, id, ex);
+ logger.error("Unregister capability {} ({}). Failed: {}", method, id, ex);
return CompletableFutureUtils.failedFuture(ex, exec);
}
@@ -405,7 +405,7 @@ public CompletableFuture unregisterCapability(Unregistration u) {
// only if "there is a registration of that capability in `receivedByClient`". So, if `remaining
// == 1`, but `toClient == null`, then the invariant is accidentally broken.
var ex = new IllegalStateException("Cannot unregister a capability for which no registration was received by the client");
- logger.trace("Unregister capability {} ({}). Failed: {}", method, id, ex);
+ logger.error("Unregister capability {} ({}). Failed: {}", method, id, ex);
return CompletableFutureUtils.failedFuture(ex, exec);
}
@@ -418,7 +418,7 @@ public CompletableFuture unregisterCapability(Unregistration u) {
}
// Case: Forwarding failed
else {
- logger.trace("Unregister capability {} ({}): Forwarded unregistration to client. Failed: {}", method, id, ex);
+ logger.error("Unregister capability {} ({}): Forwarded unregistration to client. Failed: {}", method, id, ex);
}
}, exec).thenApply(_u -> null /* Void */);
}