Skip to content
Merged
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
101 changes: 95 additions & 6 deletions ls/service/src/main/java/org/opencds/cqf/cql/ls/service/Main.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.LoggerContext;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import org.cqframework.cql.cql2elm.CqlTranslator;
import org.eclipse.lsp4j.jsonrpc.Launcher;
Expand All @@ -18,7 +21,19 @@
import org.springframework.context.annotation.Import;

/**
* This class starts a CqlLanguageServer running as a service listening on std-in/std-out
* Main entry point for the CQL Language Server service.
* <p>
* This class starts a CqlLanguageServer running as a service that communicates
* via the Language Server Protocol (LSP) over stdin/stdout. It is designed to be
* launched by LSP clients such as VS Code extensions.
* <p>
* Key behaviors:
* <ul>
* <li>Runs as a Spring Boot CommandLineRunner (non-web application)</li>
* <li>Logs to stderr to keep stdout clear for LSP communication</li>
* <li>Monitors both exit() notifications and connection closure for shutdown</li>
* <li>Terminates the JVM with System.exit(0) when the connection closes</li>
* </ul>
*/
@Import(ServerConfig.class)
public class Main implements CommandLineRunner {
Expand All @@ -39,15 +54,26 @@ public static void main(String[] args) {
CqlLanguageServer server;

@Override
public void run(String... args) throws Exception {
public void run(String... args) {
@SuppressWarnings("java:S106")
Launcher<LanguageClient> launcher = LSPLauncher.createServerLauncher(server, System.in, System.out);

LanguageClient client = launcher.getRemoteProxy();
// We actually connect to the process std-err on the client side for logging.
// We could change that and append to the client's logMessage API from the
// server-side instead.

// Logging Strategy:
// Currently logs are written to stderr (configured in logback.xml).
// The client can capture stderr and display it separately.
//
// Alternative: Send logs to the client's LSP window/logMessage API
// by uncommenting the line below. This would show logs in VS Code's
// Output panel for the language server channel.
//
// Trade-offs:
// - stderr: Simpler, works with any LSP client, easier to redirect to files
// - client API: Integrated into IDE, automatic log level filtering in UI
//
// setupClientAppender(client);

server.connect(client);
Future<Void> serverThread = launcher.startListening();

Expand All @@ -60,15 +86,78 @@ public void run(String... args) throws Exception {

log.info("cql-language-server started");

server.exited().get();
// Shutdown Strategy:
// Monitor two conditions in parallel to handle both normal and abnormal shutdowns:
// 1. server.exited() - Completes when LSP exit() notification is received (normal shutdown)
// 2. connectionClosed - Completes when stdin/stdout closes (client disconnect/crash)
//
// We wait for whichever happens first. This ensures the server terminates properly
// when VS Code closes, even if the exit() notification never arrives.

ExecutorService executor = Executors.newSingleThreadExecutor();
CompletableFuture<Void> connectionClosed = CompletableFuture.runAsync(
() -> {
try {
// This blocks until the LSP connection closes (stdin/stdout closed)
serverThread.get();
log.info("LSP connection closed");
} catch (Exception e) {
log.debug("Server thread exception", e);
}
},
executor);

// Wait for whichever completes first: exit() or connection closure
try {
CompletableFuture.anyOf(server.exited(), connectionClosed).get();
} catch (Exception e) {
log.error("Error waiting for shutdown", e);
}

log.info("Shutting down language server");
serverThread.cancel(true);
executor.shutdownNow();

// Force JVM termination to ensure all threads (including Spring-managed ones) are stopped.
// Using System.exit(0) is necessary because Spring Boot may have non-daemon threads running.
System.exit(0);
}

/**
* Configures SLF4J logging bridge to redirect java.util.logging to SLF4J.
* This ensures all logging from third-party libraries flows through our
* configured Logback appenders (currently configured to write to stderr).
*/
public static void configureLogging() {
SLF4JBridgeHandler.removeHandlersForRootLogger();
SLF4JBridgeHandler.install();
}

/**
* Alternative logging approach: Send logs to the LSP client via window/logMessage.
* <p>
* When enabled, this appender sends all log messages to the language client's
* logMessage API, which displays them in the IDE's Output panel for the
* language server channel.
* <p>
* Benefits:
* <ul>
* <li>Logs appear directly in VS Code's Output panel</li>
* <li>IDE provides UI controls for filtering log levels</li>
* <li>No need to configure separate stderr capture</li>
* </ul>
* <p>
* Drawbacks:
* <ul>
* <li>Logs can't be easily redirected to files</li>
* <li>May clutter the client connection with high-volume logging</li>
* <li>Dependent on client implementation of window/logMessage</li>
* </ul>
* <p>
* To enable: Uncomment the setupClientAppender(client) call on line 63.
*
* @param client The LSP language client to send log messages to
*/
private static void setupClientAppender(LanguageClient client) {
LoggerContext lc = (LoggerContext) LoggerFactory.getILoggerFactory();
LanguageClientAppender appender = new LanguageClientAppender(client);
Expand Down
3 changes: 2 additions & 1 deletion ls/service/src/main/resources/application.properties
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
spring.main.banner-mode=off
spring.main.log-startup-info=false
spring.profiles.active=default
spring.profiles.active=default
spring.main.web-application-type=none