diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/XMLServerLauncher.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/XMLServerLauncher.java index abdcfae6d..a4e2b2bb9 100644 --- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/XMLServerLauncher.java +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/XMLServerLauncher.java @@ -19,7 +19,6 @@ import java.net.PasswordAuthentication; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; -import java.util.concurrent.Future; import java.util.function.Function; import org.eclipse.lemminx.commons.ParentProcessWatcher; @@ -27,6 +26,7 @@ import org.eclipse.lsp4j.jsonrpc.Launcher; import org.eclipse.lsp4j.jsonrpc.MessageConsumer; import org.eclipse.lsp4j.launch.LSPLauncher.Builder; +import org.eclipse.lsp4j.launcher.multiclient.MultiClientLauncher; import org.eclipse.lsp4j.services.LanguageClient; import org.eclipse.lsp4j.services.LanguageServer; @@ -38,6 +38,31 @@ public class XMLServerLauncher { */ public static void main(String[] args) { + // Parse arguments for MCP mode + boolean mcpEnabled = false; + String workspacePath = null; + int socketPort = 0; // 0 = free port (OS chooses) + String clientName = null; + String clientVersion = null; + + for (int i = 0; i < args.length; i++) { + if ("--mcp-enabled".equals(args[i])) { + mcpEnabled = true; + } else if ("--workspace".equals(args[i]) && i + 1 < args.length) { + workspacePath = args[i + 1]; + i++; + } else if ("--port".equals(args[i]) && i + 1 < args.length) { + socketPort = Integer.parseInt(args[i + 1]); + i++; + } else if ("--client-name".equals(args[i]) && i + 1 < args.length) { + clientName = args[i + 1]; + i++; + } else if ("--client-version".equals(args[i]) && i + 1 < args.length) { + clientVersion = args[i + 1]; + i++; + } + } + final String HTTP_PROXY_HOST = System.getenv("HTTP_PROXY_HOST"); final String HTTP_PROXY_PORT = System.getenv("HTTP_PROXY_PORT"); final String HTTP_PROXY_USERNAME = System.getenv("HTTP_PROXY_USERNAME"); @@ -76,7 +101,14 @@ protected PasswordAuthentication getPasswordAuthentication() { if (!LEMMINX_DEBUG) { System.setErr(new NoOpPrintStream()); } - launch(in, out); + + // If MCP enabled, launch with shared server mode + if (mcpEnabled && workspacePath != null) { + launchWithMCP(in, out, workspacePath, socketPort, clientName, clientVersion); + } else { + // Normal stdio-only mode + launch(in, out); + } } /** @@ -88,7 +120,7 @@ protected PasswordAuthentication getPasswordAuthentication() { * (I'm not 100% sure how it meant to be used though, as * it's undocumented...) */ - public static Future launch(InputStream in, OutputStream out) { + public static void launch(InputStream in, OutputStream out) { XMLLanguageServer server = new XMLLanguageServer(); Function wrapper; if ("false".equals(System.getProperty("watchParentProcess"))) { @@ -96,9 +128,41 @@ public static Future launch(InputStream in, OutputStream out) { } else { wrapper = new ParentProcessWatcher(server); } - Launcher launcher = createServerLauncher(server, in, out, Executors.newCachedThreadPool(), wrapper); + Launcher launcher = createServerLauncher(server, in, out, Executors.newCachedThreadPool(), + wrapper); + server.setClient(launcher.getRemoteProxy()); + launcher.startListening(); + } + + /** + * Launch with MCP mode enabled - stdio + socket for shared server. + */ + private static void launchWithMCP(InputStream in, OutputStream out, String workspacePath, int socketPort, + String clientName, String clientVersion) { + XMLLanguageServer server = new XMLLanguageServer(); + Function wrapper; + if ("false".equals(System.getProperty("watchParentProcess"))) { + wrapper = it -> it; + } else { + wrapper = new ParentProcessWatcher(server); + } + MultiClientLauncher.Builder builder = new MultiClientLauncher.Builder() + .setLocalService(server) // + .setRemoteInterface(XMLLanguageClientAPI.class) // + .setInput(in) // + .setOutput(out) // + .setExecutorService(Executors.newCachedThreadPool()) // + .wrapMessages(wrapper) // + .enableSocket(socketPort, workspacePath, "lemminx"); + + // Add client info if provided + if (clientName != null || clientVersion != null) { + builder.setClientInfo(clientName, clientVersion); + } + + Launcher launcher = builder.create(); server.setClient(launcher.getRemoteProxy()); - return launcher.startListening(); + launcher.startListening(); } /** @@ -115,15 +179,14 @@ public static Future launch(InputStream in, OutputStream out) { * @param wrapper - a function for plugging in additional message * consumers */ - private static Launcher createServerLauncher(LanguageServer server, InputStream in, OutputStream out, - ExecutorService executorService, Function wrapper) { - return new Builder(). - setLocalService(server) + private static Launcher createServerLauncher(LanguageServer server, InputStream in, + OutputStream out, ExecutorService executorService, Function wrapper) { + return new Builder().setLocalService(server) // .setRemoteInterface(XMLLanguageClientAPI.class) // Set client as XML language client - .setInput(in) - .setOutput(out) - .setExecutorService(executorService) - .wrapMessages(wrapper) + .setInput(in) // + .setOutput(out) // + .setExecutorService(executorService) // + .wrapMessages(wrapper) // .create(); } } diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/XMLServerSocketLauncher.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/XMLServerSocketLauncher.java index 06b380417..efc63bac0 100644 --- a/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/XMLServerSocketLauncher.java +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lemminx/XMLServerSocketLauncher.java @@ -11,66 +11,43 @@ */ package org.eclipse.lemminx; -import java.io.InputStream; -import java.io.OutputStream; -import java.net.InetSocketAddress; -import java.nio.channels.AsynchronousServerSocketChannel; -import java.nio.channels.AsynchronousSocketChannel; -import java.nio.channels.Channels; -import java.util.Objects; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; - import org.eclipse.lemminx.customservice.XMLLanguageClientAPI; -import org.eclipse.lsp4j.jsonrpc.Launcher; -import org.eclipse.lsp4j.jsonrpc.MessageConsumer; -import org.eclipse.lsp4j.services.LanguageClient; +import org.eclipse.lsp4j.launcher.multiclient.LspSocketLauncher; +import org.eclipse.lsp4j.launcher.multiclient.MultiClientProxy; + +/** + * Launches {@link XMLLanguageServer} using asynchronous server-socket channel with multi-client support. + * Multiple clients can connect to the same server instance via TCP socket. + */ +public class XMLServerSocketLauncher extends LspSocketLauncher { -public class XMLServerSocketLauncher { + public XMLServerSocketLauncher() { + super("lemminx"); + } - private static final int DEFAULT_PORT = 5_008; - /** - * Calls {@link #launch(String[])} + * Calls {@link #launch(String[], java.util.function.Function, Class)} */ public static void main(String[] args) throws Exception { - new XMLServerSocketLauncher().launch(args); + XMLServerSocketLauncher launcher = new XMLServerSocketLauncher(); + launcher.launch(args, client -> { + XMLLanguageServer server = new XMLLanguageServer(); + server.setClient(client); + return server; + }, XMLLanguageClientAPI.class); } - + /** - * Launches {@link XMLLanguageServer} using asynchronous server-socket channel and makes it accessible through the JSON - * RPC protocol defined by the LSP. - * - * @param args standard launch arguments. may contain --port argument to change the default port 5008 + * Launch socket listener for MCP clients. + * Shares the same XMLLanguageServer instance with the stdio client. + * + * @param sharedServer the shared XMLLanguageServer instance + * @param port the port to listen on + * @param workspacePath the workspace path + * @param clientProxy the multi-client proxy to add socket clients to */ - public void launch(String[] args) throws Exception { - AsynchronousServerSocketChannel _open = AsynchronousServerSocketChannel.open(); - int _port = getPort(args); - InetSocketAddress _inetSocketAddress = new InetSocketAddress("0.0.0.0", _port); - final AsynchronousServerSocketChannel serverSocket = _open.bind(_inetSocketAddress); - while (true) { - final AsynchronousSocketChannel socketChannel = serverSocket.accept().get(); - final InputStream in = Channels.newInputStream(socketChannel); - final OutputStream out = Channels.newOutputStream(socketChannel); - final ExecutorService executorService = Executors.newCachedThreadPool(); - XMLLanguageServer languageServer = new XMLLanguageServer(); - final Launcher launcher = Launcher.createIoLauncher(languageServer, XMLLanguageClientAPI.class, - in, out, executorService, (MessageConsumer it) -> { - return it; - }); - languageServer.setClient(launcher.getRemoteProxy()); - launcher.startListening(); - } - } - - protected int getPort(final String... args) { - for (int i = 0; (i < (args.length - 1)); i++) { - String _get = args[i]; - boolean _equals = Objects.equals(_get, "--port"); - if (_equals) { - return Integer.parseInt(args[(i + 1)]); - } - } - return DEFAULT_PORT; + public void launchForSharedServer(XMLLanguageServer sharedServer, int port, String workspacePath, + MultiClientProxy clientProxy) throws Exception { + super.launchForSharedServer(sharedServer, port, workspacePath, clientProxy, XMLLanguageClientAPI.class); } } diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lsp4j/launcher/multiclient/InstancePaths.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lsp4j/launcher/multiclient/InstancePaths.java new file mode 100644 index 000000000..cda844832 --- /dev/null +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lsp4j/launcher/multiclient/InstancePaths.java @@ -0,0 +1,102 @@ +/** + * Copyright (c) 2024 Red Hat Inc. and others. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.lsp4j.launcher.multiclient; + +import java.nio.file.Path; +import java.nio.file.Paths; + +/** + * Holds pre-computed file paths for LSP server instance files. + * Calculates workspace-specific paths once in the constructor to avoid repeated string concatenation. + * Uses ${workspace}/.lsp-servers/{serverType}.json (e.g., .lsp-servers/lemminx.json) + */ +public class InstancePaths { + + private static final String INSTANCES_DIR_NAME = ".lsp-servers"; + + private final String serverType; + private final String workspacePath; + + // Pre-computed paths + private final String instanceFileName; + private final Path instanceDir; + private final Path instanceFilePath; + + private InstancePaths(String serverType, String workspacePath) { + this.serverType = serverType; + this.workspacePath = workspacePath; + + // Compute instance file name and path: ${workspace}/.lsp-servers/{serverType}.json + this.instanceFileName = serverType + ".json"; + if (workspacePath != null) { + this.instanceDir = Paths.get(workspacePath, INSTANCES_DIR_NAME); + this.instanceFilePath = instanceDir.resolve(instanceFileName); + } else { + this.instanceDir = null; + this.instanceFilePath = null; + } + } + + /** + * Creates a builder for instance paths. + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Builder for InstancePaths. + */ + public static class Builder { + private String serverType; + private String workspacePath; + + public Builder serverType(String serverType) { + this.serverType = serverType; + return this; + } + + public Builder workspacePath(String workspacePath) { + this.workspacePath = workspacePath; + return this; + } + + public InstancePaths build() { + if (serverType == null || serverType.isEmpty()) { + throw new IllegalArgumentException("serverType is required"); + } + return new InstancePaths(serverType, workspacePath); + } + } + + public String getServerType() { + return serverType; + } + + public String getWorkspacePath() { + return workspacePath; + } + + public String getInstanceFileName() { + return instanceFileName; + } + + public Path getInstanceDir() { + return instanceDir; + } + + public Path getInstanceFilePath() { + return instanceFilePath; + } + + public boolean hasWorkspace() { + return workspacePath != null && !workspacePath.isEmpty(); + } +} diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lsp4j/launcher/multiclient/LspInstanceRegistry.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lsp4j/launcher/multiclient/LspInstanceRegistry.java new file mode 100644 index 000000000..f76f9ef14 --- /dev/null +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lsp4j/launcher/multiclient/LspInstanceRegistry.java @@ -0,0 +1,107 @@ +/** + * Copyright (c) 2024 Red Hat Inc. and others. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.lsp4j.launcher.multiclient; + +import java.io.IOException; +import java.io.Reader; +import java.io.Writer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.HashMap; +import java.util.Map; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.reflect.TypeToken; + +/** + * Generic registry for language server instances running in socket mode. + * Manages instance files in ${workspace}/.lsp-servers/{serverType}.json + * Works with any LSP server type (lemminx, jdtls, qute-ls, etc.). + */ +public class LspInstanceRegistry { + + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + + /** + * Registers a language server instance. + * Writes to ${workspace}/.lsp-servers/{type}.json + * + * @param instancePaths the instance paths (containing workspace and server type) + * @param port the port the server is listening on + * @throws IOException if the file cannot be written + */ + public static synchronized void registerInstance(InstancePaths instancePaths, int port) throws IOException { + registerInstance(instancePaths, port, null, null); + } + + /** + * Registers a language server instance with client information. + * Writes to ${workspace}/.lsp-servers/{type}.json + * + * @param instancePaths the instance paths (containing workspace and server type) + * @param port the port the server is listening on + * @param clientName the name of the IDE/client (e.g., "VS Code", "IntelliJ IDEA"), can be null + * @param clientVersion the version of the IDE/client (e.g., "1.95.0"), can be null + * @throws IOException if the file cannot be written + */ + public static synchronized void registerInstance(InstancePaths instancePaths, int port, String clientName, String clientVersion) throws IOException { + if (!instancePaths.hasWorkspace()) { + return; // No workspace, nothing to write + } + + InstanceInfo info = new InstanceInfo(); + info.port = port; + info.pid = ProcessHandle.current().pid(); + info.clientName = clientName; + info.clientVersion = clientVersion; + + // Create .lsp-servers directory if needed + Files.createDirectories(instancePaths.getInstanceDir()); + + // Write instance file + try (Writer writer = Files.newBufferedWriter(instancePaths.getInstanceFilePath())) { + GSON.toJson(info, writer); + } + } + + /** + * Unregisters a language server instance. + * Deletes ${workspace}/.lsp-servers/{type}.json + * + * @param instancePaths the instance paths (containing workspace and server type) + * @throws IOException if the file cannot be deleted + */ + public static synchronized void unregisterInstance(InstancePaths instancePaths) throws IOException { + if (!instancePaths.hasWorkspace()) { + return; // No workspace, nothing to delete + } + + // Delete instance file + Files.deleteIfExists(instancePaths.getInstanceFilePath()); + + // Delete .lsp-servers directory if empty + Path dir = instancePaths.getInstanceDir(); + if (Files.exists(dir) && Files.list(dir).count() == 0) { + Files.deleteIfExists(dir); + } + } + + /** + * Instance information stored in the registry. + */ + public static class InstanceInfo { + public int port; + public long pid; + public String clientName; // e.g., "VS Code", "IntelliJ IDEA" + public String clientVersion; // e.g., "1.95.0" + } +} diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lsp4j/launcher/multiclient/LspSocketLauncher.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lsp4j/launcher/multiclient/LspSocketLauncher.java new file mode 100644 index 000000000..20225d50a --- /dev/null +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lsp4j/launcher/multiclient/LspSocketLauncher.java @@ -0,0 +1,298 @@ +/** + * Copyright (c) 2024 Red Hat Inc. and others. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.lsp4j.launcher.multiclient; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.channels.AsynchronousServerSocketChannel; +import java.nio.channels.AsynchronousSocketChannel; +import java.nio.channels.Channels; +import java.util.Objects; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.function.Function; +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.eclipse.lsp4j.InitializeResult; +import org.eclipse.lsp4j.jsonrpc.Launcher; +import org.eclipse.lsp4j.jsonrpc.MessageConsumer; +import org.eclipse.lsp4j.services.LanguageServer; + +/** + * Generic LSP server socket launcher with multi-client support. + * Multiple clients can connect to the same server instance via TCP socket. + * + * @param the server interface type (extends LanguageServer) + * @param the client interface type + */ +public class LspSocketLauncher { + + private static final Logger LOGGER = Logger.getLogger(LspSocketLauncher.class.getName()); + private static final int DEFAULT_PORT = 5_008; + + protected int port; + protected InstancePaths instancePaths; + protected boolean writeInstanceFiles = false; + protected MultiClientSocketLauncher multiClientLauncher; + protected S sharedServerInstance; + protected MultiClientProxy clientProxy; + protected InitializeResult cachedInitializeResult; + + /** + * Creates a launcher for the specified server type. + * + * @param serverType the server type identifier (e.g., "lemminx", "jdtls") + */ + public LspSocketLauncher(String serverType) { + this.instancePaths = InstancePaths.builder().serverType(serverType).build(); + } + + /** + * Launch socket listener for MCP clients. + * Shares the same server instance with the stdio client. + * + * @param sharedServer the shared server instance + * @param port the port to listen on + * @param workspacePath the workspace path (optional, can be null to disable instance files) + * @param clientProxy the multi-client proxy to add socket clients to + * @param clientInterface the client interface class + */ + public void launchForSharedServer(S sharedServer, int port, String workspacePath, + MultiClientProxy clientProxy, Class clientInterface) throws Exception { + this.port = port; + this.instancePaths = InstancePaths.builder() + .serverType(instancePaths.getServerType()) + .workspacePath(workspacePath) + .build(); + this.sharedServerInstance = sharedServer; + this.clientProxy = clientProxy; + this.writeInstanceFiles = instancePaths.hasWorkspace(); + + // Register shutdown hook + registerShutdownHook(); + + // Bind socket + final AsynchronousServerSocketChannel serverSocket = bindSocket(); + + // Write instance file (only if workspace path provided) + if (writeInstanceFiles) { + writeInstanceFile(); + } + + LOGGER.log(Level.INFO, "{0} MCP socket listener on port {1} for workspace {2} (shared server mode)", + new Object[] { instancePaths.getServerType(), this.port, instancePaths.getWorkspacePath() }); + + // Accept clients in a loop + while (true) { + final AsynchronousSocketChannel socketChannel = serverSocket.accept().get(); + acceptSocketClient(socketChannel, clientInterface); + } + } + + /** + * Launches the server in standalone socket mode. + * Each connecting client gets its own server instance. + * + * @param args standard launch arguments. may contain --port and --workspace + * @param serverFactory factory to create server instances + * @param clientInterface the client interface class + */ + public void launch(String[] args, Function serverFactory, Class clientInterface) throws Exception { + // Parse arguments + this.port = getPort(args); + String workspacePath = getWorkspace(args); + this.instancePaths = InstancePaths.builder() + .serverType(instancePaths.getServerType()) + .workspacePath(workspacePath) + .build(); + this.writeInstanceFiles = instancePaths.hasWorkspace(); + + // Register shutdown hook for cleanup + registerShutdownHook(); + + // Bind socket with retry + final AsynchronousServerSocketChannel serverSocket = bindSocket(); + + // Write instance file (after successful bind, so port is confirmed) + if (writeInstanceFiles) { + writeInstanceFile(); + } + + LOGGER.log(Level.INFO, "{0} multi-client server listening on port {1} for workspace {2}", + new Object[] { instancePaths.getServerType(), this.port, instancePaths.getWorkspacePath() }); + + // Create the multi-client launcher + this.multiClientLauncher = new MultiClientSocketLauncher<>(serverFactory, clientInterface); + + // Accept clients in a loop + while (true) { + final AsynchronousSocketChannel socketChannel = serverSocket.accept().get(); + multiClientLauncher.acceptClient(socketChannel); + LOGGER.log(Level.INFO, "Client connected. Total clients: {0}", multiClientLauncher.getClientCount()); + } + } + + /** + * Accept a socket client connection and add it to the multi-client proxy. + */ + private void acceptSocketClient(AsynchronousSocketChannel socketChannel, Class clientInterface) throws Exception { + // Wait for initialize result from stdio client + while (cachedInitializeResult == null) { + Thread.sleep(100); + } + + final InputStream in = Channels.newInputStream(socketChannel); + final OutputStream out = Channels.newOutputStream(socketChannel); + final ExecutorService executorService = Executors.newCachedThreadPool(); + + // Create launcher for socket client with wrapper that returns cached result + final Launcher launcher = Launcher.createIoLauncher( + new SecondaryClientServerWrapper(sharedServerInstance, cachedInitializeResult), + clientInterface, + in, out, executorService, + (MessageConsumer it) -> it + ); + + // Add this socket client to the multi-client proxy so it receives publishDiagnostics etc. + C socketClient = launcher.getRemoteProxy(); + clientProxy.addClient(socketClient); + + // Start listening + launcher.startListening(); + + LOGGER.log(Level.INFO, "Socket client connected. Total clients: {0}", clientProxy.getClientCount()); + } + + protected int getPort(final String... args) { + for (int i = 0; (i < (args.length - 1)); i++) { + String _get = args[i]; + boolean _equals = Objects.equals(_get, "--port"); + if (_equals) { + return Integer.parseInt(args[(i + 1)]); + } + } + return DEFAULT_PORT; + } + + protected String getWorkspace(final String... args) { + for (int i = 0; (i < (args.length - 1)); i++) { + String _get = args[i]; + boolean _equals = Objects.equals(_get, "--workspace"); + if (_equals) { + return args[(i + 1)]; + } + } + return null; + } + + private void writeInstanceFile() { + if (!writeInstanceFiles) { + return; + } + try { + LspInstanceRegistry.registerInstance(instancePaths, this.port); + LOGGER.log(Level.INFO, "Registered {0} instance in {1}", + new Object[] { + instancePaths.getServerType(), + instancePaths.getInstanceFilePath() + }); + } catch (IOException e) { + LOGGER.log(Level.WARNING, "Failed to write instance file", e); + } + } + + protected AsynchronousServerSocketChannel bindSocket() throws Exception { + // If port is 0, let OS choose + if (this.port == 0) { + LOGGER.log(Level.INFO, "Port is 0, asking OS to choose a free port"); + AsynchronousServerSocketChannel channel = AsynchronousServerSocketChannel.open(); + InetSocketAddress address = new InetSocketAddress("0.0.0.0", 0); + channel.bind(address); + + InetSocketAddress localAddress = (InetSocketAddress) channel.getLocalAddress(); + if (localAddress == null) { + throw new Exception("Failed to get local address after binding to port 0"); + } + + int chosenPort = localAddress.getPort(); + LOGGER.log(Level.INFO, "OS chose port {0}, updating this.port from {1} to {2}", + new Object[] { chosenPort, this.port, chosenPort }); + + this.port = chosenPort; + + LOGGER.log(Level.INFO, "After update, this.port is now {0}", this.port); + return channel; + } + + // Otherwise try the specified port, then port+1, port+2... (max 10 attempts) + int maxRetries = 10; + int currentPort = this.port; + + for (int i = 0; i < maxRetries; i++) { + try { + AsynchronousServerSocketChannel channel = AsynchronousServerSocketChannel.open(); + InetSocketAddress address = new InetSocketAddress("0.0.0.0", currentPort); + channel.bind(address); + + if (currentPort != this.port) { + LOGGER.log(Level.WARNING, "Port {0} was in use, bound to port {1} instead", + new Object[] { this.port, currentPort }); + this.port = currentPort; + } else { + LOGGER.log(Level.INFO, "Successfully bound to port {0}", this.port); + } + + return channel; + } catch (java.net.BindException e) { + if (i < maxRetries - 1) { + LOGGER.log(Level.WARNING, "Port {0} is in use, trying {1}", + new Object[] { currentPort, currentPort + 1 }); + currentPort++; + } else { + throw new Exception("Could not bind to any port from " + this.port + " to " + currentPort, e); + } + } + } + + throw new Exception("Failed to bind socket after " + maxRetries + " attempts"); + } + + private void registerShutdownHook() { + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + if (writeInstanceFiles) { + try { + LspInstanceRegistry.unregisterInstance(instancePaths); + LOGGER.log(Level.INFO, "Unregistered {0} instance from {1}", + new Object[] { + instancePaths.getServerType(), + instancePaths.getInstanceFilePath() + }); + } catch (IOException e) { + LOGGER.log(Level.WARNING, "Failed to remove instance file entry", e); + } + } + })); + } + + /** + * Set the initialize result from the stdio client. + * Must be called after the stdio client completes initialization. + */ + public void setInitializeResult(InitializeResult result) { + this.cachedInitializeResult = result; + if (multiClientLauncher != null) { + multiClientLauncher.setInitializeResult(result); + } + LOGGER.log(Level.INFO, "Cached initialize result for socket clients"); + } +} diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lsp4j/launcher/multiclient/MultiClientLauncher.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lsp4j/launcher/multiclient/MultiClientLauncher.java new file mode 100644 index 000000000..d790a0f42 --- /dev/null +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lsp4j/launcher/multiclient/MultiClientLauncher.java @@ -0,0 +1,374 @@ +/** + * Copyright (c) 2026 Red Hat Inc. and others. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.lsp4j.launcher.multiclient; + +import java.io.IOException; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.nio.channels.AsynchronousServerSocketChannel; +import java.nio.channels.AsynchronousSocketChannel; +import java.util.concurrent.CompletableFuture; +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.eclipse.lsp4j.InitializeResult; +import org.eclipse.lsp4j.jsonrpc.Launcher; +import org.eclipse.lsp4j.services.LanguageServer; + +/** + * Extension of LSP4J Launcher.Builder that supports multiple clients connecting to a shared server. + * + *

Usage example: + *

+ * new MultiClientLauncher.Builder<LanguageClient>()
+ *     .setLocalService(server)
+ *     .setRemoteInterface(LanguageClient.class)
+ *     .setInput(in)
+ *     .setOutput(out)
+ *     .setExecutorService(executor)
+ *     .wrapMessages(wrapper)
+ *     .enableSocket(port, workspacePath, "lemminx")
+ *     .create();
+ * 
+ */ +public class MultiClientLauncher { + + private static final Logger LOGGER = Logger.getLogger(MultiClientLauncher.class.getName()); + + /** + * Wrapper that returns the multi-client proxy instead of the primary client. + */ + private static class LauncherWrapper implements Launcher { + private final Launcher delegate; + private final C multiClientProxy; + + LauncherWrapper(Launcher delegate, C multiClientProxy) { + this.delegate = delegate; + this.multiClientProxy = multiClientProxy; + } + + @Override + public java.util.concurrent.Future startListening() { + return delegate.startListening(); + } + + @Override + public C getRemoteProxy() { + // Return multi-client proxy so server.setClient() sets the broadcast proxy + return multiClientProxy; + } + + @Override + public org.eclipse.lsp4j.jsonrpc.RemoteEndpoint getRemoteEndpoint() { + return delegate.getRemoteEndpoint(); + } + } + + /** + * Builder that extends LSP4J's Launcher.Builder with multi-client socket support. + */ + public static class Builder extends Launcher.Builder { + + // Captured from parent builder calls + private Object localService; + private Class remoteInterface; + + // Socket mode fields + private int socketPort = -1; + private String workspacePath; + private String serverType; + private String clientName; + private String clientVersion; + private SocketListenerManager socketManager; + + // Override parent methods to capture values and return our Builder type + @Override + @SuppressWarnings("unchecked") + public Builder setLocalService(Object localService) { + this.localService = localService; + super.setLocalService(localService); + return this; + } + + @Override + @SuppressWarnings("unchecked") + public Builder setRemoteInterface(Class remoteInterface) { + this.remoteInterface = (Class) remoteInterface; + super.setRemoteInterface(remoteInterface); + return this; + } + + @Override + public Builder setInput(java.io.InputStream input) { + super.setInput(input); + return this; + } + + @Override + public Builder setOutput(java.io.OutputStream output) { + super.setOutput(output); + return this; + } + + @Override + public Builder setExecutorService(java.util.concurrent.ExecutorService executorService) { + super.setExecutorService(executorService); + return this; + } + + @Override + public Builder wrapMessages(java.util.function.Function wrapper) { + super.wrapMessages(wrapper); + return this; + } + + /** + * Enable socket mode for secondary clients. + * + * @param port the port to listen on (0 for automatic free port) + * @param workspacePath the workspace path (for instance file) + * @param serverType the server type identifier (e.g., "lemminx", "jdtls") + * @return this builder + */ + public Builder enableSocket(int port, String workspacePath, String serverType) { + this.socketPort = port; + this.workspacePath = workspacePath; + this.serverType = serverType; + return this; + } + + /** + * Set client information to be written to the instance file. + * Optional - if not called, clientName and clientVersion will be null. + * + * @param clientName the name of the IDE/client (e.g., "VS Code", "IntelliJ IDEA") + * @param clientVersion the version of the IDE/client (e.g., "1.95.0") + * @return this builder + */ + public Builder setClientInfo(String clientName, String clientVersion) { + this.clientName = clientName; + this.clientVersion = clientVersion; + return this; + } + + @Override + @SuppressWarnings("unchecked") + public Launcher create() { + if (socketPort < 0 || workspacePath == null || serverType == null) { + // Normal single-client mode + return super.create(); + } + + // Multi-client mode - wrap the server and setup socket listener + LanguageServer originalServer = (LanguageServer) localService; + + // Create multi-client proxy for broadcasting + C multiClientProxy = MultiClientProxy.create(remoteInterface); + MultiClientProxy proxyHandler = (MultiClientProxy) Proxy.getInvocationHandler(multiClientProxy); + + // Create socket manager to handle the InitializeResult capture and socket listener + socketManager = new SocketListenerManager(originalServer, proxyHandler, remoteInterface, + socketPort, workspacePath, serverType, clientName, clientVersion); + + // Wrap server to intercept initialize() + LanguageServer wrappedServer = socketManager.wrapServer(); + + // Create primary launcher using parent builder with wrapped server + super.setLocalService(wrappedServer); + Launcher primaryLauncher = super.create(); + + // Add primary client to proxy + proxyHandler.addClient(primaryLauncher.getRemoteProxy()); + + // Start socket listener in background + socketManager.startSocketListener(); + + // Return a launcher wrapper that returns multiClientProxy instead of the primary client + return new LauncherWrapper<>(primaryLauncher, multiClientProxy); + } + } + + /** + * Manages socket listener and InitializeResult capture for secondary clients. + */ + private static class SocketListenerManager { + private final LanguageServer originalServer; + private final MultiClientProxy clientProxy; + private final Class clientInterface; + private final int port; + private final String workspacePath; + private final String serverType; + private final String clientName; + private final String clientVersion; + private volatile InitializeResult cachedResult; + + SocketListenerManager(LanguageServer server, MultiClientProxy proxy, Class clientInterface, + int port, String workspacePath, String serverType, String clientName, String clientVersion) { + this.originalServer = server; + this.clientProxy = proxy; + this.clientInterface = clientInterface; + this.port = port; + this.workspacePath = workspacePath; + this.serverType = serverType; + this.clientName = clientName; + this.clientVersion = clientVersion; + } + + LanguageServer wrapServer() { + return (LanguageServer) Proxy.newProxyInstance( + originalServer.getClass().getClassLoader(), + new Class[] { LanguageServer.class }, + new InitializeInterceptor(originalServer, this) + ); + } + + void setInitializeResult(InitializeResult result) { + this.cachedResult = result; + System.err.println("[MultiClientLauncher] Cached InitializeResult for secondary clients"); + } + + InitializeResult getInitializeResult() { + return cachedResult; + } + + void waitForInitialize() throws InterruptedException { + while (cachedResult == null) { + Thread.sleep(100); + } + } + + void startSocketListener() { + InstancePaths paths = InstancePaths.builder() + .serverType(serverType) + .workspacePath(workspacePath) + .build(); + + Thread thread = new Thread(() -> { + try { + runSocketListener(paths); + } catch (Exception e) { + System.err.println("[MultiClientLauncher] Socket listener failed: " + e.getMessage()); + e.printStackTrace(System.err); + } + }, "Socket-Listener-" + serverType); + + thread.setDaemon(true); + thread.start(); + } + + @SuppressWarnings("unchecked") + private void runSocketListener(InstancePaths paths) throws Exception { + LspSocketLauncher socketLauncher = + new LspSocketLauncher<>(serverType); + + // Bind socket and write instance file + socketLauncher.port = port; + socketLauncher.instancePaths = paths; + socketLauncher.writeInstanceFiles = paths.hasWorkspace(); + + AsynchronousServerSocketChannel serverSocket = socketLauncher.bindSocket(); + + if (socketLauncher.writeInstanceFiles) { + try { + LspInstanceRegistry.registerInstance(paths, socketLauncher.port, clientName, clientVersion); + System.err.println("[MultiClientLauncher] Registered " + serverType + + " instance in " + paths.getInstanceFilePath()); + } catch (IOException e) { + System.err.println("[MultiClientLauncher] Failed to write instance file: " + e.getMessage()); + } + } + + // Accept secondary clients + System.err.println("[MultiClientLauncher] " + serverType + + " socket listener ready on port " + socketLauncher.port); + + while (true) { + AsynchronousSocketChannel socketChannel = serverSocket.accept().get(); + acceptSecondaryClient(socketChannel); + } + } + + @SuppressWarnings("unchecked") + private void acceptSecondaryClient(AsynchronousSocketChannel socketChannel) throws Exception { + // Wait for primary client to initialize + waitForInitialize(); + + var in = java.nio.channels.Channels.newInputStream(socketChannel); + var out = java.nio.channels.Channels.newOutputStream(socketChannel); + var executor = java.util.concurrent.Executors.newCachedThreadPool(); + + // Wrap server for secondary client + LanguageServer wrappedForSecondary = new SecondaryClientServerWrapper(originalServer, cachedResult); + + // Create launcher for secondary client + Launcher secondaryLauncher = new Launcher.Builder<>() + .setLocalService(wrappedForSecondary) + .setRemoteInterface(clientInterface) + .setInput(in) + .setOutput(out) + .setExecutorService(executor) + .create(); + + // Add to multi-client proxy + Object remoteProxy = secondaryLauncher.getRemoteProxy(); + ((MultiClientProxy) clientProxy).addClient(remoteProxy); + + System.err.println("[MultiClientLauncher] Secondary client connected. Total clients: " + + clientProxy.getClientCount()); + + // Start listening - returns a Future that completes when connection closes + java.util.concurrent.Future listening = secondaryLauncher.startListening(); + + // Monitor connection in background thread - when it closes, remove client + Thread cleanupThread = new Thread(() -> { + try { + listening.get(); // Blocks until connection closes + } catch (Exception e) { + // Connection closed (normal or error) + } finally { + System.err.println("[MultiClientLauncher] Secondary client disconnected, removing from proxy"); + ((MultiClientProxy) clientProxy).removeClient(remoteProxy); + System.err.println("[MultiClientLauncher] Remaining clients: " + clientProxy.getClientCount()); + } + }, "Cleanup-" + System.identityHashCode(remoteProxy)); + cleanupThread.setDaemon(true); + cleanupThread.start(); + } + } + + /** + * InvocationHandler that intercepts initialize() to cache the result. + */ + private static class InitializeInterceptor implements InvocationHandler { + private final LanguageServer delegate; + private final SocketListenerManager manager; + + InitializeInterceptor(LanguageServer delegate, SocketListenerManager manager) { + this.delegate = delegate; + this.manager = manager; + } + + @Override + @SuppressWarnings("unchecked") + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + Object result = method.invoke(delegate, args); + + if ("initialize".equals(method.getName()) && result instanceof CompletableFuture) { + return ((CompletableFuture) result).thenApply(initResult -> { + manager.setInitializeResult(initResult); + return initResult; + }); + } + + return result; + } + } +} diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lsp4j/launcher/multiclient/MultiClientProxy.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lsp4j/launcher/multiclient/MultiClientProxy.java new file mode 100644 index 000000000..f250d32b5 --- /dev/null +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lsp4j/launcher/multiclient/MultiClientProxy.java @@ -0,0 +1,156 @@ +/** + * Copyright (c) 2024 Red Hat Inc. and others. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.lsp4j.launcher.multiclient; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.Iterator; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.logging.Logger; + +/** + * Generic proxy that broadcasts LSP notifications to multiple connected + * clients. For request methods (that return CompletableFuture), only the first + * client is called. + * + * This class is generic and can be used with any LSP client interface. + * + * @param the client interface type + */ +public class MultiClientProxy implements InvocationHandler { + + private static final Logger LOGGER = Logger.getLogger(MultiClientProxy.class.getName()); + + private final List clients = new CopyOnWriteArrayList<>(); + private final Class clientInterface; + + private MultiClientProxy(Class clientInterface) { + this.clientInterface = clientInterface; + } + + /** + * Creates a proxy instance that broadcasts to multiple clients. + * + * @param the client interface type + * @param clientInterface the client interface class + * @return a proxy instance + */ + @SuppressWarnings("unchecked") + public static T create(Class clientInterface) { + MultiClientProxy handler = new MultiClientProxy<>(clientInterface); + return (T) Proxy.newProxyInstance(clientInterface.getClassLoader(), new Class[] { clientInterface }, + handler); + } + + /** + * Adds a client to receive broadcasts. + */ + public void addClient(T client) { + clients.add(client); + System.err.println("[MultiClientProxy] Client added to " + clientInterface.getSimpleName() + ". Total clients: " + + clients.size()); + } + + /** + * Removes a client from broadcasts. + */ + public void removeClient(T client) { + clients.remove(client); + System.err.println("[MultiClientProxy] Client removed from " + clientInterface.getSimpleName() + + ". Total clients: " + clients.size()); + } + + /** + * Returns true if there are any connected clients. + */ + public boolean hasClients() { + return !clients.isEmpty(); + } + + /** + * Returns the number of connected clients. + */ + public int getClientCount() { + return clients.size(); + } + + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + String methodName = method.getName(); + Class returnType = method.getReturnType(); + + // For notification methods (void or CompletableFuture), broadcast to all + // clients + if (returnType == void.class || isVoidFuture(method)) { + broadcastNotification(method, args); + return returnType == void.class ? null : CompletableFuture.completedFuture(null); + } + + // For request methods that expect a response, call only the first client + if (!clients.isEmpty()) { + return method.invoke(clients.get(0), args); + } + + // No clients connected + if (returnType == CompletableFuture.class) { + return CompletableFuture.completedFuture(null); + } + return null; + } + + private void broadcastNotification(Method method, Object[] args) { + // Broadcast to all clients + // Note: LSP4J proxies are async, so method.invoke() won't throw on disconnect + // Cleanup happens via the launcher.startListening() Future callback + for (T client : clients) { + try { + method.invoke(client, args); + } catch (Throwable e) { + // Should rarely happen since LSP4J is async, but log just in case + System.err.println("[MultiClientProxy] Unexpected error broadcasting " + method.getName() + + ": " + e.getClass().getSimpleName() + ": " + e.getMessage()); + } + } + } + + /** + * Check if the exception indicates a connection error (client disconnected). + */ + private boolean isConnectionError(Exception e) { + Throwable cause = e; + while (cause != null) { + String message = cause.getMessage(); + if (message != null) { + // Check for various connection error patterns + if (message.contains("connection was aborted") || message.contains("Connection reset") + || message.contains("Broken pipe") || message.contains("Stream closed") + || message.contains("An established connection was aborted")) { + return true; + } + } + cause = cause.getCause(); + } + return false; + } + + private boolean isVoidFuture(Method method) { + if (method.getReturnType() != CompletableFuture.class) { + return false; + } + // Check if it's CompletableFuture by method name convention + // LSP notification methods typically start with these prefixes + String name = method.getName(); + return name.startsWith("telemetry") || name.startsWith("publish") || name.startsWith("show") + || name.startsWith("log"); + } +} diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lsp4j/launcher/multiclient/MultiClientSocketLauncher.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lsp4j/launcher/multiclient/MultiClientSocketLauncher.java new file mode 100644 index 000000000..5c6ccb390 --- /dev/null +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lsp4j/launcher/multiclient/MultiClientSocketLauncher.java @@ -0,0 +1,165 @@ +/** + * Copyright (c) 2024 Red Hat Inc. and others. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.lsp4j.launcher.multiclient; + +import java.io.InputStream; +import java.io.OutputStream; +import java.lang.reflect.Proxy; +import java.nio.channels.AsynchronousSocketChannel; +import java.nio.channels.Channels; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.function.Function; +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.eclipse.lsp4j.InitializeResult; +import org.eclipse.lsp4j.jsonrpc.Launcher; +import org.eclipse.lsp4j.jsonrpc.MessageConsumer; +import org.eclipse.lsp4j.services.LanguageServer; + +/** + * Generic multi-client socket launcher for LSP servers. + * Manages a shared language server instance and allows multiple clients to connect. + * + * @param the server interface type (extends LanguageServer) + * @param the client interface type + */ +public class MultiClientSocketLauncher { + + private static final Logger LOGGER = Logger.getLogger(MultiClientSocketLauncher.class.getName()); + + private final S sharedServer; + private final Class clientInterface; + private final MultiClientProxy multiClientProxy; + private InitializeResult cachedInitializeResult; + private boolean isPrimaryClient = true; + private final Object lock = new Object(); + + /** + * Creates a multi-client launcher. + * + * @param serverFactory factory to create the shared server instance + * @param clientInterface the client interface class + */ + @SuppressWarnings("unchecked") + public MultiClientSocketLauncher(Function serverFactory, Class clientInterface) { + this.clientInterface = clientInterface; + + // Create the multi-client proxy + C proxyClient = MultiClientProxy.create(clientInterface); + this.multiClientProxy = (MultiClientProxy) Proxy.getInvocationHandler(proxyClient); + + // Create the shared server with the multi-client proxy as its client + this.sharedServer = serverFactory.apply(proxyClient); + } + + /** + * Accepts a new client connection. + * + * @param socketChannel the socket channel for the new client + * @throws Exception if connection fails + */ + public void acceptClient(AsynchronousSocketChannel socketChannel) throws Exception { + final InputStream in = Channels.newInputStream(socketChannel); + final OutputStream out = Channels.newOutputStream(socketChannel); + final ExecutorService executorService = Executors.newCachedThreadPool(); + + synchronized (lock) { + if (isPrimaryClient) { + acceptPrimaryClient(in, out, executorService); + } else { + acceptSecondaryClient(in, out, executorService); + } + } + } + + private void acceptPrimaryClient(InputStream in, OutputStream out, ExecutorService executorService) { + LOGGER.log(Level.INFO, "Primary client connecting..."); + + // Create launcher for primary client + final Launcher launcher = Launcher.createIoLauncher( + sharedServer, + clientInterface, + in, out, executorService, + (MessageConsumer it) -> it + ); + + // Add this client to the multi-client proxy + C client = launcher.getRemoteProxy(); + multiClientProxy.addClient(client); + + // Start listening + Future listening = launcher.startListening(); + + // Hook into the initialize response to cache it + // We need to intercept the server's initialize method completion + // For now, we'll use a simple approach with a callback + isPrimaryClient = false; + + LOGGER.log(Level.INFO, "Primary client connected successfully"); + } + + private void acceptSecondaryClient(InputStream in, OutputStream out, ExecutorService executorService) + throws InterruptedException { + LOGGER.log(Level.INFO, "Secondary client connecting..."); + + // Wait for primary client to initialize + while (cachedInitializeResult == null) { + Thread.sleep(100); + } + + // Wrap the server to return cached initialize result + LanguageServer wrappedServer = new SecondaryClientServerWrapper(sharedServer, cachedInitializeResult); + + // Create launcher for secondary client + final Launcher launcher = Launcher.createIoLauncher( + wrappedServer, + clientInterface, + in, out, executorService, + (MessageConsumer it) -> it + ); + + // Add this client to the multi-client proxy + C client = launcher.getRemoteProxy(); + multiClientProxy.addClient(client); + + // Start listening + launcher.startListening(); + + LOGGER.log(Level.INFO, "Secondary client connected successfully"); + } + + /** + * Called when the primary client completes initialization. + * This allows us to cache the result for secondary clients. + * + * @param result the initialize result + */ + public void setInitializeResult(InitializeResult result) { + this.cachedInitializeResult = result; + LOGGER.log(Level.INFO, "Cached initialize result for secondary clients"); + } + + /** + * Returns the number of connected clients. + */ + public int getClientCount() { + return multiClientProxy.getClientCount(); + } + + /** + * Returns the shared server instance. + */ + public S getSharedServer() { + return sharedServer; + } +} diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lsp4j/launcher/multiclient/README.md b/org.eclipse.lemminx/src/main/java/org/eclipse/lsp4j/launcher/multiclient/README.md new file mode 100644 index 000000000..629cb9115 --- /dev/null +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lsp4j/launcher/multiclient/README.md @@ -0,0 +1,378 @@ +# LSP4J Multi-Client Launcher + +Generic multi-client support for Language Server Protocol servers based on Eclipse LSP4J. + +## Purpose + +This package provides a **generic, reusable** framework for LSP servers that need to support **multiple clients connecting simultaneously** to the same server instance via TCP sockets. + +### Problem + +By default, LSP servers are designed for a 1:1 relationship between client and server: +- Traditional stdio transport: one stdin/stdout pair per process +- Each client spawns its own server process +- Memory and CPU duplication when multiple clients need the same server + +### Solution + +The multi-client launcher allows: +- ✅ **Multiple clients** connecting to the same server process via TCP sockets +- ✅ **Shared server instance** - one language server instance serves all clients +- ✅ **Broadcast notifications** - server notifications (diagnostics, logs) sent to all connected clients +- ✅ **Independent requests** - each client can make requests independently +- ✅ **Transparent initialization** - first client initializes normally, subsequent clients receive cached capabilities +- ✅ **Auto-cleanup** - disconnected clients are automatically removed from broadcast list + +## Architecture + +``` +┌─────────────────────────────────────────────────────────┐ +│ MultiClientLauncher.Builder │ +│ │ +│ Primary Client (stdio) ←────────┐ │ +│ ↓ │ │ +│ ┌──────────────────────┐ │ │ +│ │ Language Server │ │ │ +│ │ (single instance) │ │ │ +│ └──────────────────────┘ │ │ +│ ↓ │ │ +│ ┌──────────────────────┐ │ │ +│ │ MultiClientProxy │ ←───────┤ │ +│ │ (broadcasts) │ │ │ +│ └──────────────────────┘ │ │ +│ ↓ ↓ │ │ +│ Client 1 Client 2 ... Client N │ +│ (stdio) (socket) (socket) │ +└────────┬──────────┬─────────────────────────────────────┘ + │ │ + stdio Socket 1, Socket 2, ... +``` + +## Components + +### 1. **MultiClientLauncher.Builder** + +Fluent API that extends LSP4J's `Launcher.Builder` with multi-client socket support. + +**Usage:** +```java +XMLLanguageServer server = new XMLLanguageServer(); + +Launcher launcher = new MultiClientLauncher.Builder() + .setLocalService(server) + .setRemoteInterface(XMLLanguageClientAPI.class) + .setInput(in) + .setOutput(out) + .setExecutorService(Executors.newCachedThreadPool()) + .wrapMessages(wrapper) + .enableSocket(0, workspacePath, "lemminx") // 0 = free port + .create(); + +server.setClient(launcher.getRemoteProxy()); +launcher.startListening(); +``` + +**Key Features:** +- Extends standard LSP4J `Launcher.Builder` API +- `.enableSocket(port, workspacePath, serverType)` activates multi-client mode +- Port 0 = automatic free port selection +- Returns a wrapper that provides `MultiClientProxy` via `getRemoteProxy()` + +### 2. **MultiClientProxy** + +Dynamic proxy that broadcasts LSP notifications to all connected clients. + +**Behavior:** +- **Notifications** (void methods, `publishDiagnostics`, etc.) → broadcast to all clients +- **Requests** (methods returning `CompletableFuture`) → sent to first client only +- **Auto-cleanup** - clients that disconnect (IOException) are automatically removed + +**Created automatically:** +```java +// When server calls: +server.getClient().publishDiagnostics(...); +// → All connected clients receive the notification +``` + +### 3. **SecondaryClientServerWrapper** + +Wrapper for secondary clients that intercepts lifecycle methods. + +**Behavior:** +- `initialize()` → returns cached `InitializeResult` from first client +- `initialized()` → no-op +- `shutdown()` → no-op (doesn't shutdown the shared server) +- `exit()` → no-op +- All other methods → delegated to the shared server + +### 4. **LspInstanceRegistry** + +Manages instance files for service discovery. + +**File location:** `${workspace}/.lsp-servers/{serverType}.json` + +**File format:** +```json +{ + "port": 54321, + "pid": 12345 +} +``` + +**Usage:** +```java +InstancePaths paths = InstancePaths.builder() + .serverType("lemminx") + .workspacePath("/path/to/workspace") + .build(); + +// Register instance +LspInstanceRegistry.registerInstance(paths, port); + +// Cleanup on shutdown +LspInstanceRegistry.unregisterInstance(paths); +``` + +### 5. **InstancePaths** + +Pre-computes file paths to avoid duplication. + +**Usage:** +```java +InstancePaths paths = InstancePaths.builder() + .serverType("lemminx") + .workspacePath("/path/to/workspace") + .build(); + +Path instanceFile = paths.getInstanceFilePath(); +// → /path/to/workspace/.lsp-servers/lemminx.json +``` + +## How It Works + +### Primary Client Connects (stdio) + +1. `MultiClientLauncher.Builder` creates the server +2. Wraps server with `InitializeInterceptor` to capture `InitializeResult` +3. Creates `MultiClientProxy` for broadcasting +4. Primary client connects via stdio, calls `initialize()` +5. Server initializes normally, result is cached +6. Primary client added to `MultiClientProxy` +7. Socket listener starts in background + +### Secondary Clients Connect (socket) + +1. Client discovers instance via `${workspace}/.lsp-servers/lemminx.json` +2. Client connects to socket +3. Server wraps itself with `SecondaryClientServerWrapper` +4. Client calls `initialize()` → wrapper returns **cached result** +5. Client added to `MultiClientProxy` +6. All requests go to shared server + +### Notifications Flow + +``` +Server: client.publishDiagnostics(...) + ↓ + MultiClientProxy + ↓ + ┌───────┼───────┐ + ▼ ▼ ▼ +Primary Client2 Client3 +(stdio) (socket) (socket) +``` + +### Client Disconnection + +When a client disconnects: +1. Next broadcast attempt throws `IOException` +2. `MultiClientProxy` detects connection error +3. Client automatically removed from broadcast list +4. Server continues serving remaining clients + +## Use Cases + +### 1. IDE + MCP Server Co-existence + +A user has VS Code with an XML extension open, and simultaneously uses Claude Code with an MCP server that needs XML language intelligence. + +**Without multi-client:** +- VS Code launches lemminx process #1 +- MCP server launches lemminx process #2 +- 2× memory, 2× indexing, 2× CPU + +**With multi-client:** +- VS Code launches lemminx in multi-client mode +- MCP server connects to the same lemminx instance +- Shared memory, shared index, shared cache + +### 2. Multi-Workspace Scenarios + +Multiple workspace folders, all needing the same language server. + +### 3. Testing and Development + +Attach a test client to a running server without disrupting the main client. + +## Integration Example: lemminx + +### Server Side (XMLServerLauncher.java) + +```java +public static void main(String[] args) { + boolean mcpEnabled = hasArg("--mcp-enabled", args); + String workspacePath = getArg("--workspace", args); + int socketPort = getIntArg("--port", args, 0); // 0 = free port + String clientName = getArg("--client-name", args); + String clientVersion = getArg("--client-version", args); + + XMLLanguageServer server = new XMLLanguageServer(); + + if (mcpEnabled && workspacePath != null) { + // Multi-client mode with socket + MultiClientLauncher.Builder builder = new MultiClientLauncher.Builder() + .setLocalService(server) + .setRemoteInterface(XMLLanguageClientAPI.class) + .setInput(System.in) + .setOutput(System.out) + .setExecutorService(Executors.newCachedThreadPool()) + .wrapMessages(new ParentProcessWatcher(server)) + .enableSocket(socketPort, workspacePath, "lemminx"); + + // Add client info if provided (optional) + if (clientName != null || clientVersion != null) { + builder.setClientInfo(clientName, clientVersion); + } + + Launcher launcher = builder.create(); + server.setClient(launcher.getRemoteProxy()); + launcher.startListening(); + } else { + // Normal stdio-only mode + launch(System.in, System.out); + } +} +``` + +### Client Side: vscode-xml + +#### Configuration + +Add to VS Code settings: +```json +{ + "xml.mcp.enabled": true +} +``` + +#### What happens + +When `xml.mcp.enabled: true`: +1. vscode-xml launches lemminx with arguments: + ```bash + java -jar lemminx.jar \ + --mcp-enabled \ + --workspace /path/to/workspace \ + --port 0 \ + --client-name "VS Code" \ + --client-version "1.95.0" + ``` +2. lemminx starts in multi-client mode: + - Primary client: vscode-xml via stdio + - Socket listener: accepts secondary clients +3. Instance file created: `${workspace}/.lsp-servers/lemminx.json` + ```json + { + "port": 54321, + "pid": 12345, + "clientName": "VS Code", + "clientVersion": "1.95.0" + } + ``` +4. MCP servers can now discover and connect to this instance + +#### TypeScript Implementation (javaServerStarter.ts) + +```typescript +export async function prepareJavaExecutable( + context: ExtensionContext, + requirements: RequirementsData, + xmlJavaExtensions: string[] +): Promise { + + const mcpEnabled = getXMLConfiguration().get('mcp.enabled', false); + const workspacePath = workspace.workspaceFolders?.[0]?.uri.fsPath || ''; + + return { + command: path.resolve(requirements.java_home + '/bin/java'), + args: prepareParams(requirements, xmlJavaExtensions, context, mcpEnabled, workspacePath) + }; +} + +function prepareParams(..., mcpEnabled?: boolean, workspacePath?: string): string[] { + // ... build classpath and JVM args ... + + params.push('org.eclipse.lemminx.XMLServerLauncher'); + + // Add MCP arguments if enabled + if (mcpEnabled && workspacePath) { + params.push('--mcp-enabled'); + params.push('--workspace'); + params.push(workspacePath); + + // Optional: add client info for better instance tracking + params.push('--client-name'); + params.push('VS Code'); + params.push('--client-version'); + params.push(vscode.version); + } + + return params; +} +``` + +### Client Side: MCP Server + +```java +// Try to find existing instance +String workspacePath = Paths.get(workspaceRoot).toString(); +InstanceRegistry.InstanceInfo instance = + LspInstanceRegistry.findInstance(workspacePath, "lemminx"); + +if (instance != null) { + // Connect to existing instance + Socket socket = new Socket("localhost", instance.port); + Launcher launcher = LSPLauncher.createClientLauncher( + client, + socket.getInputStream(), + socket.getOutputStream(), + executorService, + wrapper + ); + languageServer = launcher.getRemoteProxy(); + launcher.startListening(); +} else { + // Launch own standalone instance + launchStandaloneServer(); +} +``` + +## Requirements + +- **Java 11+** (for `ProcessHandle.current().pid()`) +- **Eclipse LSP4J 0.21+** + +## License + +Eclipse Public License v2.0 (EPL-2.0) + +## Contributing + +This package is designed to be **generic and reusable**. If you use it with a different language server (JDT.LS, typescript-language-server, etc.), please contribute improvements and documentation! + +## See Also + +- [Eclipse LSP4J](https://github.com/eclipse/lsp4j) +- [Language Server Protocol Specification](https://microsoft.github.io/language-server-protocol/) +- [lemminx (XML Language Server)](https://github.com/eclipse/lemminx) +- [vscode-xml (VS Code XML extension)](https://github.com/redhat-developer/vscode-xml) diff --git a/org.eclipse.lemminx/src/main/java/org/eclipse/lsp4j/launcher/multiclient/SecondaryClientServerWrapper.java b/org.eclipse.lemminx/src/main/java/org/eclipse/lsp4j/launcher/multiclient/SecondaryClientServerWrapper.java new file mode 100644 index 000000000..78785d8a1 --- /dev/null +++ b/org.eclipse.lemminx/src/main/java/org/eclipse/lsp4j/launcher/multiclient/SecondaryClientServerWrapper.java @@ -0,0 +1,78 @@ +/** + * Copyright (c) 2024 Red Hat Inc. and others. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.lsp4j.launcher.multiclient; + +import java.util.concurrent.CompletableFuture; + +import org.eclipse.lsp4j.InitializeParams; +import org.eclipse.lsp4j.InitializeResult; +import org.eclipse.lsp4j.InitializedParams; +import org.eclipse.lsp4j.services.LanguageServer; +import org.eclipse.lsp4j.services.TextDocumentService; +import org.eclipse.lsp4j.services.WorkspaceService; + +/** + * Generic wrapper for secondary clients connecting to an already-initialized server. + * Intercepts initialize/initialized to return cached capabilities without + * re-initializing the underlying server. + * + * This class is generic and can be used with any LSP LanguageServer implementation. + */ +public class SecondaryClientServerWrapper implements LanguageServer { + + private final LanguageServer delegate; + private final InitializeResult cachedResult; + + /** + * Creates a wrapper for secondary clients. + * + * @param delegate the underlying language server (already initialized) + * @param cachedResult the initialize result from the primary client + */ + public SecondaryClientServerWrapper(LanguageServer delegate, InitializeResult cachedResult) { + this.delegate = delegate; + this.cachedResult = cachedResult; + } + + @Override + public CompletableFuture initialize(InitializeParams params) { + // Return cached capabilities without touching the underlying server + return CompletableFuture.completedFuture(cachedResult); + } + + @Override + public void initialized(InitializedParams params) { + // No-op: server is already initialized by the primary client + } + + @Override + public CompletableFuture shutdown() { + // Don't actually shutdown the shared server when a secondary client disconnects + // Just acknowledge the shutdown request + return CompletableFuture.completedFuture(null); + } + + @Override + public void exit() { + // No-op: server continues running for other clients + } + + @Override + public TextDocumentService getTextDocumentService() { + // Delegate all actual LSP operations to the shared server + return delegate.getTextDocumentService(); + } + + @Override + public WorkspaceService getWorkspaceService() { + // Delegate all actual LSP operations to the shared server + return delegate.getWorkspaceService(); + } +}