Skip to content

Commit 0ac9107

Browse files
committed
fix: Avoid UI freezes on Open Declaration with slow lang servers
Block the UI thread for max 200ms. If LS does not respond return DeferredOpenDeclarationHyperlink instance which asynchronously opens link once LS responded.
1 parent b55a82f commit 0ac9107

3 files changed

Lines changed: 315 additions & 34 deletions

File tree

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/*******************************************************************************
2+
* Copyright (c) 2025 Vegard IT GmbH and others.
3+
* This program and the accompanying materials are made
4+
* available under the terms of the Eclipse Public License 2.0
5+
* which is available at https://www.eclipse.org/legal/epl-2.0/
6+
*
7+
* SPDX-License-Identifier: EPL-2.0
8+
*
9+
* Contributors:
10+
* Sebastian Thomschke (Vegard IT GmbH) - initial implementation.
11+
*******************************************************************************/
12+
package org.eclipse.lsp4e.internal;
13+
14+
import java.util.Collections;
15+
import java.util.Map;
16+
import java.util.WeakHashMap;
17+
import java.util.concurrent.CompletableFuture;
18+
import java.util.concurrent.ConcurrentHashMap;
19+
import java.util.concurrent.ConcurrentMap;
20+
import java.util.concurrent.TimeUnit;
21+
import java.util.function.Supplier;
22+
23+
import org.eclipse.jdt.annotation.Nullable;
24+
import org.eclipse.jface.text.IDocument;
25+
26+
/**
27+
* Generic, per-document+offset cache for asynchronous results that avoids
28+
* starting the same work twice by sharing a single running task.
29+
*
30+
* <p>
31+
* Features:
32+
* <li>Weakly keys by {@link IDocument} to avoid memory leaks.
33+
* <li>Per-document concurrent maps for thread-safe access from UI and
34+
* background.
35+
* <li>TTL-based eviction using {@link System#nanoTime()} for monotonic timing.
36+
* <li>Atomic in-flight de-duplication using
37+
* {@link ConcurrentMap#computeIfAbsent(Object, java.util.function.Function)} to
38+
* ensure only one running task per document+offset.
39+
*/
40+
public final class DocumentOffsetAsyncCache<V> {
41+
42+
private static final class Entry<V> {
43+
final long createdNanos;
44+
final V value;
45+
46+
Entry(final V value) {
47+
this.value = value;
48+
this.createdNanos = System.nanoTime();
49+
}
50+
51+
boolean expired(final long ttlNanos) {
52+
return System.nanoTime() - createdNanos > ttlNanos;
53+
}
54+
}
55+
56+
private final Map<IDocument, ConcurrentMap<Integer, Entry<V>>> cache = Collections
57+
.synchronizedMap(new WeakHashMap<>());
58+
private final Map<IDocument, ConcurrentMap<Integer, CompletableFuture<V>>> inFlight = Collections
59+
.synchronizedMap(new WeakHashMap<>());
60+
61+
private final long ttlNanos;
62+
63+
public DocumentOffsetAsyncCache(final long ttlMillis) {
64+
this.ttlNanos = TimeUnit.MILLISECONDS.toNanos(ttlMillis);
65+
}
66+
67+
/**
68+
* Returns a completed future with a cached value when available; otherwise,
69+
* returns the single running task for this {@code doc+offset}, or starts a new
70+
* one using {@code supplier}. On successful completion, the result is cached.
71+
*/
72+
public CompletableFuture<V> computeIfAbsent(final IDocument doc, final int offset,
73+
final Supplier<CompletableFuture<V>> supplier) {
74+
// Fast path: return a completed future if a fresh value is already cached
75+
final @Nullable V cachedNow = getNow(doc, offset);
76+
if (cachedNow != null) {
77+
return CompletableFuture.completedFuture(cachedNow);
78+
}
79+
80+
final ConcurrentMap<Integer, CompletableFuture<V>> byOffset;
81+
synchronized (inFlight) {
82+
byOffset = inFlight.computeIfAbsent(doc, d -> new ConcurrentHashMap<>());
83+
}
84+
return byOffset.computeIfAbsent(offset, k -> {
85+
final CompletableFuture<V> cf = supplier.get();
86+
cf.whenComplete((v, t) -> {
87+
// Always clean up the in-flight entry by key. Only one future exists
88+
// per offset due to computeIfAbsent, so this is safe and avoids capturing
89+
// a specific future instance.
90+
byOffset.remove(offset);
91+
if (t == null && v != null) {
92+
put(doc, offset, v);
93+
}
94+
});
95+
return cf;
96+
});
97+
}
98+
99+
public @Nullable V getNow(final IDocument doc, final int offset) {
100+
final ConcurrentMap<Integer, Entry<V>> byOffset = cache.get(doc);
101+
if (byOffset == null) {
102+
return null;
103+
}
104+
final Entry<V> e = byOffset.get(offset);
105+
if (e == null) {
106+
return null;
107+
}
108+
if (e.expired(ttlNanos)) {
109+
byOffset.remove(offset, e);
110+
return null;
111+
}
112+
return e.value;
113+
}
114+
115+
public void invalidate(final IDocument doc) {
116+
cache.remove(doc); // synchronizedMap handles its own locking
117+
final var map = inFlight.remove(doc); // remove returns the per-doc map, if any
118+
if (map != null) {
119+
map.values().forEach(f -> f.cancel(true));
120+
}
121+
}
122+
123+
public void put(final IDocument doc, final int offset, final V value) {
124+
final ConcurrentMap<Integer, Entry<V>> byOffset;
125+
synchronized (cache) {
126+
byOffset = cache.computeIfAbsent(doc, d -> new ConcurrentHashMap<>());
127+
}
128+
byOffset.put(offset, new Entry<>(value));
129+
}
130+
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/*******************************************************************************
2+
* Copyright (c) 2025 Vegard IT GmbH and others.
3+
* This program and the accompanying materials are made
4+
* available under the terms of the Eclipse Public License 2.0
5+
* which is available at https://www.eclipse.org/legal/epl-2.0/
6+
*
7+
* SPDX-License-Identifier: EPL-2.0
8+
*
9+
* Contributors:
10+
* Sebastian Thomschke (Vegard IT GmbH) - initial implementation.
11+
*******************************************************************************/
12+
package org.eclipse.lsp4e.operations.declaration;
13+
14+
import java.util.concurrent.CompletableFuture;
15+
import java.util.concurrent.CompletionException;
16+
import java.util.concurrent.TimeUnit;
17+
18+
import org.eclipse.jdt.annotation.Nullable;
19+
import org.eclipse.jface.text.IDocument;
20+
import org.eclipse.jface.text.IDocumentExtension4;
21+
import org.eclipse.jface.text.IRegion;
22+
import org.eclipse.jface.text.ITextViewer;
23+
import org.eclipse.jface.text.hyperlink.IHyperlink;
24+
import org.eclipse.lsp4e.LanguageServerPlugin;
25+
26+
final class DeferredOpenDeclarationHyperlink implements IHyperlink {
27+
28+
private static final long DEFFERRED_OPEN_TIMEOUT_NANOS = TimeUnit.SECONDS.toNanos(5);
29+
30+
private final ITextViewer viewer;
31+
private final IDocument document;
32+
private final long documentInitialModificationStamp;
33+
private final IRegion region;
34+
private final CompletableFuture<@Nullable IHyperlink> future;
35+
private final long createdNanos = System.nanoTime();
36+
37+
DeferredOpenDeclarationHyperlink(final ITextViewer viewer, final IDocument document, final IRegion region,
38+
final CompletableFuture<@Nullable IHyperlink> future) {
39+
this.viewer = viewer;
40+
this.document = document;
41+
this.region = region;
42+
this.future = future;
43+
this.documentInitialModificationStamp = document instanceof IDocumentExtension4 ext //
44+
? ext.getModificationStamp()
45+
: -1;
46+
}
47+
48+
@Override
49+
public IRegion getHyperlinkRegion() {
50+
return region;
51+
}
52+
53+
@Override
54+
public @Nullable String getTypeLabel() {
55+
final var link = getResolvedLink();
56+
return link != null ? link.getTypeLabel() : null;
57+
}
58+
59+
@Override
60+
public @Nullable String getHyperlinkText() {
61+
final var link = getResolvedLink();
62+
return link != null ? link.getHyperlinkText() : null;
63+
}
64+
65+
@Override
66+
public void open() {
67+
future.whenComplete((link, ex) -> {
68+
if (ex != null || link == null) {
69+
LanguageServerPlugin.logWarning("No hyperlink target resolved for Open Declaration"); //$NON-NLS-1$
70+
return;
71+
}
72+
final var widget = viewer.getTextWidget();
73+
if (widget == null) {
74+
return;
75+
}
76+
widget.getDisplay().asyncExec(() -> {
77+
if (!isStale()) {
78+
link.open();
79+
}
80+
});
81+
});
82+
}
83+
84+
private @Nullable IHyperlink getResolvedLink() {
85+
try {
86+
return future.getNow(null);
87+
} catch (CompletionException e) {
88+
return null;
89+
}
90+
}
91+
92+
private boolean isStale() {
93+
// LS response came too late
94+
if (System.nanoTime() - createdNanos > DEFFERRED_OPEN_TIMEOUT_NANOS) {
95+
return true;
96+
}
97+
98+
// Editor was closed
99+
final var widget = viewer.getTextWidget();
100+
if (widget == null || widget.isDisposed()) {
101+
return true;
102+
}
103+
104+
// Document was modified
105+
if (document instanceof IDocumentExtension4 ext) {
106+
long now = ext.getModificationStamp();
107+
if (documentInitialModificationStamp != -1 && now != documentInitialModificationStamp) {
108+
return true;
109+
}
110+
}
111+
112+
return false;
113+
}
114+
}

0 commit comments

Comments
 (0)