The language server with child threads

Improve the stability and performance of VSCode extensions

Alena Khineika
DailyJS

--

For single-threaded languages such as JavaScript, it might be a situation when a long-running operation blocks the entire application. For example, some code calculates a value using a complex algorithm that significantly incurs CPU and memory usage. Or a controller waits for a response from a query that runs against a huge database without properly configured indexes. You might want to collect metrics, analyze data structures, parse files, etc and do all of this not in the main thread but in the background process to avoid performance costs and ensure the stability of the application.

In JavaScript, each task in the event loop should be processed completely before any other task can be processed next. On the one hand, it simplifies the program without worrying about concurrency issues. On the other hand, it blocks the execution of any other JavaScript code, even the UI becomes unresponsive, and the user can’t interact with the application anymore.

To prevent this bottleneck we can move the CPU-intensive computations out of JavaScript’s event loop to separate worker threads that run tasks in parallel and use all available CPU cores automatically.

Background processes in VSCode

Currently, I am working on the VSCode extension that uses a text editor as a playground for executing JavaScript code with MongoDB Shell API support.

The challenge is that a user of the extension can write any code in the editor, execution of which can lead to unexpected results. We don’t want to force the user to reopen the VSCode workspace each time they accidentally built an infinite loop. At the same time, we want to allow users to write any possible code because making mistakes is a natural part of the learning process. So even the buggy code has the right to exist in the playground.

To solve this problem we decided to take advantage of the language server that we added to our extension to support MongoDB Shell signatures and autocompletion. The VSCode language server follows the Microsoft LSP specification and can run in its own process to avoid performance issues, and communicate with the code editor through the language server protocol.

The language server protocol (LSP) is mainly used as a tool between the editor (the client) and a language smartness provider (the server) to integrate features like autocomplete, go to definition, find all references, list document symbols, signature help and much more. You can find the complete list of supported features in the official documentation.

It is possible to implement most of these features by directly using VSCode language API. The language server, however, provides an alternative way of implementing such language support, which gives you more flexibility, more configuration options, and the possibility to share the code with other applications or even external developer tools such as Sublime Text, JetBrains, Atom, etc.

Language features can be resource-consuming. Validation and autocompletion processes require parsing of the user input, building, and traversing abstract syntax trees. Since the language server can perform all these actions in a background process, the VSCode’s performance remains unaffected.

We can also extend the language server and client with custom methods to leverage it as a background worker. Because the language server is a separate JSON RPC enabled process, it provides an option to handle any processing of the main UI thread.

But what will happen if one of delegated to the language server assignments includes an infinite loop? The language server design does not allow interaction with the server directly. All the requests to the language server should be passed by the language client. In this case, if the server is unresponsive because of handling a never-ending operation, there is nothing that the client can do to interrupt this process.

Unfortunately, the language server API does not support creating child processes, but we can use native node modules instead, and delegate this task to worker threads.

In this blog post, we will:

  • generate a simple VSCode extension;
  • configure the language server with an example of the autocomplete feature;
  • add a long-running operation with the possibility to cancel it;
  • explain why the language server can’t cancel an infinite loop;
  • use worker threads to be able to terminate infinite loops.

If you prefer to jump right into the code here is an example with the language server and threads.

VSCode extension with a language server

Following the instruction in the “Get Started” guide let’s use the VSCode extension generator to scaffold a TypeScript project ready for development.

The easiest way to add the language server to the extension is to copy the client and the server boilerplates from the VSCode extension samples repository to your extension folder (extension.ts and server.ts files) and install the required vscode-languageclient and vscode-languageserver dependencies.

To be able to run the extension with the language server support we first need to apply some configurations. In the server.ts file, we create a text document manager using the vscode-languageserver-textdocument module.

We specify TextDocumentSyncKind.Full to sync documents by always sending the full text.

We want to activate the extension as soon as a plain text file is opened (for example a file with the extension `.txt`), therefore we add “onLanguage:plaintext” event to the activation events list in package.json.

To configure the client part of the language server I renamed extension.ts to client.ts to use the traditional client/server terminology and changed the path to the server according to our application file structure.

I also added untitled schema document selector to the language client options to enable language server features to the newly opened documents that are not saved to the file system yet.

The last step is to activate the language server in the extension.ts file.

Now when we run the extension and open a new file, we can start benefit from the autocompletion feature provided by the language server. Try to type javascript in the editor and you will see that the extension proposes the two words TypeScript and JavaScript.

This is one of the language features provided by the language server boilerplates we downloaded from the examples repository. Lots of useful information you can learn from comments in the code or by reading the “Language Server Extension Guide”.

Now we can switch to the initial problem we wanted to solve, which is handling long-running operations and explaining why the language server can’t handle all use cases.

Add the long-running operation with the possibility to cancel it

As you might be noticed the VSCode generator added the `Hello World` command to the extension by default. Let’s replace a realization with a function that pretends to run a time-consuming operation and while running shows a progress modal with the possibility to cancel the process.

In code written like this, preforming the long-running operation happens in the main thread of the extension and can freeze the UI. To prevent such unpleasant user experience we can delegate the execution of the timeout function to the language server.

We create executeSleep() and cancelSleep() functions in the language client. The executeSleep() function waits for the language client to be ready, and instantiates new CancellationTokenSource() class. The cancellation source generates cancellation tokens that are being shared between the client and the server.

The cancelSleep() function sends a request for cancellation to the language server. As a result, the associated CancellationToken will be notified of the cancellation, the onCancellationRequested() event will be fired, and isCancellationRequested will return true.

The server listens for executeSleep requests from the client along with onCancellationRequested events fired by the CancellationTokenSource.

The benefit of this implementation is that now the execution of the sleep() function happens not in the main thread of the extension but in the parallel process. And if we want to terminate this process or reload the language server completely, we keep our extension running and users can continue clicking around.

Note: The debug information printed by connection.console.log() can be found in the output view of the running extension. Select Language Server Example in the dropdown to switch to the language server output.

Now it is time to talk about the limitations of this implementation.

Why the language server can’t cancel infinite loops

We moved our time-consuming function to a separate process and by doing this improved the performance of the main UI thread, but we still didn’t address the issue we talked about at the beginning of this blog post. What if the sleep() function instead of using a simple timeout will evaluate the user code that contains an infinite loop? It will freeze the server, making it unresponsive to client commands, and we no longer can call client.stop() and client.start() to reload the language server.

Use worker threads to be able to terminate infinite loops

The Node’s worker threads module provides API for moving CPU-bound tasks out of Node’s event loop to JavaScript threads that run in parallel in the background and can be terminated at any point from the parent thread. This is the exact functionality we were missing when using the VSCode language server API.

Creating a worker requires two arguments. The first argument is a path to the file that contains the worker’s implementation, and a second argument is an object with a property called workerData. This data is shared between the main thread and its children.

Note: There is an issue with support for `.ts` files. Trying to run a `.ts` file in a worker thread throws the error: ‘The worker script extension must be “.js” or “.mjs”. Received “.ts”’. As a workaround, we can specify a path to the`.js` worker module inside of the `out` folder when creating an instance of the worker.

Communication between threads is happening using an event model and the postMessage() method. In the example below, I use parentPort and the postMessage() method to send a result back to the main thread when the executeSleep() function finished the job.

Now when we call the executeSleep() function in the extension.ts file it sends a request to the language client, which adds a cancellation token to the request and transfers it further to the language server. The language server first of all constantly listens for cancellation events and secondly creates a child thread that becomes responsible for handling a long-running operation. The child thread tries to complete the assignment and return a result or error back to the language server when ready. If something went wrong and the language server did not receive any messages back from the child thread, it can terminate the unresponsive thread, and all JavaScript executions that happen there as soon as the cancelation event is requested.

And here is an example of the infinite loop in the MongoDB playground. Playgrounds are JavaScript environments where users can write MongoDB commands using shell syntax and see the results instantly.

The while loop keeps running until the user clicks the Cancel button at the bottom right-hand corner of the editor, and there is no UI issues because the main thread is not responsible for the JavaScript code evaluation.

You can find more information about MongoDB for VSCode and all its features in the documentation.

To summarise

I used Node’s worker threads to isolate long-running operations and to be able to terminate infinite loops. I couldn’t do it using only the VSCode language server API, because the design of the language server does not allow us to interact with the server directly. Only the client of the language server can send requests to the server, and in case of infinite loops, the server becomes unresponsive and can't be stopped by the client.

I also used CancellationTokenSource class to listen for cancelation events in the language server. The cancellation tokens are largely used in VSCode to terminate user-facing processes. For example, the progress component itself is also built using CancellationTokenSource API.

Despite the fact that the language server can’t gracefully terminate infinite loops it is anyway a very powerful tool provided by VSCode. All language features available in the editor view can be implemented using the language server API. Furthermore, any LSP-compliant language server can integrate with multiple LSP-compliant code editors and vise versa. You can use it as a single source of truth for the semantics of your applications and as a reusable codebase for LSP-compliant projects.

--

--