Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions org.eclipse.lsp4e/src/org/eclipse/lsp4e/LSPEclipseUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -415,7 +415,7 @@ public static CallHierarchyPrepareParams toCallHierarchyPrepareParams(int offset
} else if (buffer != null && buffer.getFileStore() != null) {
return buffer.getFileStore().toURI();
}
return null;
return Adapters.adapt(document, URI.class, true);
}

private static @Nullable IPath toPath(@Nullable IFileBuffer buffer) {
Expand Down Expand Up @@ -1364,7 +1364,12 @@ public static URI toUri(File file) {

public static @Nullable IFile getFile(@Nullable IDocument document) {
IPath path = toPath(document);
return getFile(path);
IFile file = getFile(path);
//if we cannot determine file via buffer manager then try to adapt from document.
if (file == null) {
file = Adapters.adapt(document, IFile.class, true);
}
return file;
}

/**
Expand Down
131 changes: 131 additions & 0 deletions org.eclipse.lsp4e/src/org/eclipse/lsp4e/LanguageServerWrapper.java
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
import java.util.TimerTask;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
Expand Down Expand Up @@ -92,6 +93,7 @@
import org.eclipse.lsp4j.DidChangeWatchedFilesParams;
import org.eclipse.lsp4j.DidChangeWatchedFilesRegistrationOptions;
import org.eclipse.lsp4j.DidChangeWorkspaceFoldersParams;
import org.eclipse.lsp4j.DidSaveTextDocumentParams;
import org.eclipse.lsp4j.DocumentFormattingOptions;
import org.eclipse.lsp4j.DocumentOnTypeFormattingOptions;
import org.eclipse.lsp4j.DocumentRangeFormattingOptions;
Expand Down Expand Up @@ -134,6 +136,10 @@

public class LanguageServerWrapper {

// Debounce map: records last time a file buffer listener handled an event for a given URI.
private final ConcurrentHashMap<URI, Long> bufferEventTimestamps = new ConcurrentHashMap<>();
private static final long BUFFER_EVENT_DEBOUNCE_MS = 1000L; // 1 second

private final IFileBufferListener fileBufferListener = new LSFileBufferListener();

private final class LSFileBufferListener extends FileBufferListenerAdapter {
Expand All @@ -142,6 +148,7 @@ private final class LSFileBufferListener extends FileBufferListenerAdapter {
public void bufferDisposed(IFileBuffer buffer) {
final var uri = LSPEclipseUtils.toUri(buffer);
if (uri != null) {
bufferEventTimestamps.put(uri, System.currentTimeMillis());
disconnect(uri);
}
}
Expand All @@ -151,6 +158,10 @@ public void stateChanging(IFileBuffer buffer) {
if (buffer.isDirty()) {
DocumentContentSynchronizer documentListener = connectedDocuments.get(LSPEclipseUtils.toUri(buffer));
if (documentListener != null) {
final var uri = LSPEclipseUtils.toUri(buffer);
if (uri != null) {
bufferEventTimestamps.put(uri, System.currentTimeMillis());
}
documentListener.documentAboutToBeSaved();
}
}
Expand All @@ -163,6 +174,10 @@ public void dirtyStateChanged(IFileBuffer buffer, boolean isDirty) {
}
DocumentContentSynchronizer documentListener = connectedDocuments.get(LSPEclipseUtils.toUri(buffer));
if (documentListener != null) {
final var uri = LSPEclipseUtils.toUri(buffer);
if (uri != null) {
bufferEventTimestamps.put(uri, System.currentTimeMillis());
}
documentListener.documentSaved(buffer);
}
}
Expand All @@ -177,6 +192,7 @@ public void underlyingFileMoved(IFileBuffer buffer, IPath newPath) {
if (documentListener == null) {
return;
}
bufferEventTimestamps.put(oldUri, System.currentTimeMillis());
LSPEclipseUtils.disconnectFromFileBuffer(buffer.getLocation());
/*
* This below is not working (will leak file buffer), because the client that connected the document
Expand Down Expand Up @@ -205,6 +221,7 @@ public void underlyingFileDeleted(IFileBuffer buffer) {
if (oldUri == null) {
return;
}
bufferEventTimestamps.put(oldUri, System.currentTimeMillis());
if (!isConnectedTo(oldUri)) {
return;
}
Expand Down Expand Up @@ -286,6 +303,109 @@ synchronized void close() {
private final FileSystemWatcherManager fileSystemWatcherManager;
private final WatchedFilesListener watchedFilesListener = new WatchedFilesListener();

// Fallback workspace listener to catch resource-level events for files that are not backed by file buffers
private final IResourceChangeListener resourceFallbackListener = new ResourceFallbackListener();

private final class ResourceFallbackListener implements IResourceChangeListener {

@Override
public void resourceChanged(IResourceChangeEvent event) {
// Handle PRE_DELETE / PRE_CLOSE that carry the resource directly
if (event.getType() == IResourceChangeEvent.PRE_DELETE
|| event.getType() == IResourceChangeEvent.PRE_CLOSE) {
IResource res = event.getResource();
if (res instanceof IFile file) {
URI uri = LSPEclipseUtils.toUri(file);
if (uri != null) {
// If buffer listener recently handled this URI, skip to avoid duplicate handling
Long last = bufferEventTimestamps.get(uri);
if (last != null && System.currentTimeMillis() - last < BUFFER_EVENT_DEBOUNCE_MS) {
// skip duplicate workspace handling
return;
}
DocumentContentSynchronizer dcs = connectedDocuments.get(uri);
if (dcs != null) {
// Mirror buffer.stateChanging -> documentAboutToBeSaved
try {
dcs.documentAboutToBeSaved();
} catch (Exception e) {
LanguageServerPlugin.logError(e);
}
}
if (isConnectedTo(uri)) {
LanguageServerPlugin.logInfo("Workspace PRE_DELETE/PRE_CLOSE disconnect for: " + uri); //$NON-NLS-1$
disconnectTextFileBuffer(uri);
disconnect(uri);
}
Comment on lines +326 to +339
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ResourceFallbackListener runs on the workspace resource notification thread, but it directly reads connectedDocuments/calls isConnectedTo(...). connectedDocuments is a plain HashMap and elsewhere writes are guarded with synchronized (connectedDocuments), so this introduces racy access and potential map corruption/CMEs. Please make access to connectedDocuments consistently thread-safe (e.g., guard reads/writes with the same lock, or switch to a concurrent map and update disconnect(...) accordingly, or marshal resource events onto a single thread before touching the map).

Suggested change
DocumentContentSynchronizer dcs = connectedDocuments.get(uri);
if (dcs != null) {
// Mirror buffer.stateChanging -> documentAboutToBeSaved
try {
dcs.documentAboutToBeSaved();
} catch (Exception e) {
LanguageServerPlugin.logError(e);
}
}
if (isConnectedTo(uri)) {
LanguageServerPlugin.logInfo("Workspace PRE_DELETE/PRE_CLOSE disconnect for: " + uri); //$NON-NLS-1$
disconnectTextFileBuffer(uri);
disconnect(uri);
}
synchronized (connectedDocuments) {
DocumentContentSynchronizer dcs = connectedDocuments.get(uri);
if (dcs != null) {
// Mirror buffer.stateChanging -> documentAboutToBeSaved
try {
dcs.documentAboutToBeSaved();
} catch (Exception e) {
LanguageServerPlugin.logError(e);
}
}
if (isConnectedTo(uri)) {
LanguageServerPlugin.logInfo("Workspace PRE_DELETE/PRE_CLOSE disconnect for: " + uri); //$NON-NLS-1$
disconnectTextFileBuffer(uri);
disconnect(uri);
}
}

Copilot uses AI. Check for mistakes.
}
}
return;
}

// For POST_CHANGE examine the delta for file-level removals/moves/replacements and content changes
if (event.getType() != IResourceChangeEvent.POST_CHANGE || event.getDelta() == null) {
return;
}
try {
event.getDelta().accept(delta -> {
IResource r = delta.getResource();
if (r.getType() != IResource.FILE) {
return true; // continue visiting
}
IFile file = (IFile) r;
int kind = delta.getKind();
int flags = delta.getFlags();

// Content change (save/replace) -> attempt to notify documentSaved
if (kind == IResourceDelta.CHANGED && (flags & IResourceDelta.CONTENT) != 0) {
URI uri = LSPEclipseUtils.toUri(file);
if (uri != null) {
// If buffer listener recently handled this URI, skip to avoid duplicate handling
Long last = bufferEventTimestamps.get(uri);
if (last != null && System.currentTimeMillis() - last < BUFFER_EVENT_DEBOUNCE_MS) {
return false; // skip this file
}
DocumentContentSynchronizer dcs = connectedDocuments.get(uri);
if (dcs != null) {
try {
// Mirror buffer.dirtyStateChanged(..., false) -> documentSaved
final var identifier = LSPEclipseUtils.toTextDocumentIdentifier(uri);
final var params = new DidSaveTextDocumentParams(identifier, dcs.getDocument().get());
// send didSave notification via wrapper to keep ordering
LanguageServerWrapper.this.sendNotification(ls -> ls.getTextDocumentService().didSave(params));
} catch (Exception e) {
LanguageServerPlugin.logError(e);
}
}
}
}

// Moved/Removed/Replacement -> disconnect equivalent to underlyingFileMoved/underlyingFileDeleted
if (kind == IResourceDelta.REMOVED
|| (kind == IResourceDelta.CHANGED && ((flags & IResourceDelta.MOVED_FROM) != 0
|| (flags & IResourceDelta.MOVED_TO) != 0
|| (flags & IResourceDelta.REPLACED) != 0))) {
URI uri = LSPEclipseUtils.toUri(file);
if (uri != null && isConnectedTo(uri)) {
// If buffer listener recently handled this URI, skip to avoid duplicate handling
Long last = bufferEventTimestamps.get(uri);
if (last != null && System.currentTimeMillis() - last < BUFFER_EVENT_DEBOUNCE_MS) {
return false;
}
LanguageServerPlugin.logInfo("Workspace resource change disconnect for: " + uri); //$NON-NLS-1$
disconnectTextFileBuffer(uri);
disconnect(uri);
}
}

return false; // no need to recurse into children of a file
});
} catch (CoreException e) {
LanguageServerPlugin.logError(e);
}
}
}

/* Backwards compatible constructor */
public LanguageServerWrapper(IProject project, LanguageServerDefinition serverDefinition) {
this(project, serverDefinition, null);
Expand Down Expand Up @@ -514,6 +634,11 @@ private synchronized void start(boolean forceRestart) {
}
});
FileBuffers.getTextFileBufferManager().addFileBufferListener(fileBufferListener);
// Register a workspace-level fallback listener to catch resource events for
// files not backed by buffers
ResourcesPlugin.getWorkspace().addResourceChangeListener(resourceFallbackListener,
IResourceChangeEvent.POST_CHANGE | IResourceChangeEvent.PRE_DELETE
| IResourceChangeEvent.PRE_CLOSE);
castNonNull(initializeFuture).thenRunAsync(() -> {
processErrorStream(castNonNull(context.lspStreamProvider), l -> LanguageServerPlugin.getDefault().getLog().error(l), e -> {throw new UncheckedIOException(e);});
}, errorProcessor);
Expand Down Expand Up @@ -755,6 +880,8 @@ private void shutdown(LanguageServerContext workingContext) {
}

FileBuffers.getTextFileBufferManager().removeFileBufferListener(fileBufferListener);
ResourcesPlugin.getWorkspace().removeResourceChangeListener(resourceFallbackListener);

}

public @Nullable CompletableFuture<LanguageServerWrapper> connect(@Nullable IDocument document, IFile file) {
Expand Down Expand Up @@ -895,6 +1022,10 @@ private boolean supportsWorkspaceFolderCapability() {
documentListener.documentClosed();
disconnectTextFileBuffer(uri);
}
// Clean up debounce map entry to avoid unbounded growth and avoid stale skips
if (uri != null) {
bufferEventTimestamps.remove(uri);
}
if (this.connectedDocuments.isEmpty()) {
if (this.serverDefinition.lastDocumentDisconnectedTimeout != 0) {
startStopTimerTask();
Expand Down
Loading