Developer Tools · Editors and IDEs

Coordinating Multiple Language Servers Across Workspace Folders

What happens when one buffer is claimed by several servers and one workspace has several roots: document selectors, folder change notifications, per-scope configuration, and watched-file events.

The protocol was designed around a single server and a single project root: one process, told where the project begins, receiving the documents the user opens. Editors stopped matching that shape almost immediately. A markup file is claimed by an HTML server, a style server, and a script server at once; a repository holds several projects with different toolchains; a language has a compiler-backed server and a separate linter; and a single session may run a dozen processes, each believing it is the only source of truth about the buffer. Nothing in the wire protocol arbitrates between them. What exists instead is a set of scoping mechanisms (selectors, folder lists, configuration scopes, and file watches) that the client composes into a coherent editor, and the composition rules are where the interesting failures live.

One document, several claimants

Feature routing is client policy, not protocol. When two servers both advertise completion, the client decides whether to merge the lists, to prefer one by selector specificity, or to ask both and interleave the results. Formatting admits no such freedom: two servers that both format a range produce two edits over the same span, and applying both yields text neither intended. Clients therefore assign exclusive ownership of the mutating features (formatting, range formatting, rename, code actions that edit) to a single server per document, typically the one whose selector is most specific, while read-only features such as hover and diagnostics are merged.

Merging is not free either. Each server’s reply arrives on its own schedule, so a merged list is assembled from answers of different ages, and a hover shown for a symbol that one server resolved and another could not looks like an inconsistency to the reader. The client’s only defence is to attach the server’s identity to each result, which is why items from different servers retain their own source labels. Highlighting follows the same principle in a different form: each server declares its own token legend and delta chain, so the client keeps one cached array per server per document instead of merging the token streams into a single set of annotations.

Routing by document selector

A document selector is a list of filters, each naming a language identifier, a URI scheme, and a glob pattern against the URI. A server registered with a selector receives notifications and requests only for documents that match at least one filter; a filter with only a language matches every file the client classifies that way, whether on disk, in a version-control view, or in memory behind an unsaved buffer.

from fnmatch import fnmatch

def matches(selector, doc):
    for f in selector:
        if "language" in f and f["language"] != doc.language:
            continue
        if "scheme" in f and f["scheme"] != doc.scheme:
            continue
        if "pattern" in f and not fnmatch(doc.path, f["pattern"]):
            continue
        return True
    return False

Three properties of that function shape real behaviour. An empty filter matches everything, so a server that registers with an empty object claims the whole workspace. A filter with no pattern matches every path within the scheme, including build output and vendored dependencies, which is how a server ends up indexing a directory the user considers off limits. And the language identifier is assigned by the client, not derived from the extension, so the same file opened with an override, such as a template file treated as HTML, changes which servers see it and which features exist for it.

Because selectors can overlap freely, a document may legitimately be claimed by any number of servers, and each receives its own open, change, and close notifications. That duplication is the source of the mirrored-state problems described in Incremental Document Synchronization Between Client and Server, multiplied by the number of claimants: a unit bug in one server’s applying code desynchronises that server alone, and the visible symptom is one wrong feature among correct ones, which reads as a bug in the server rather than in the transport.

Workspace folders and the end of the single root

The original handshake carried one root URI. The replacement is a list of workspace folders, each with a URI and a display name, sent at initialization alongside a capability flag saying the client understands folders at all. The single root URI remains in the parameters for compatibility, and servers are expected to treat the folder list as authoritative when it is present.

A live list needs a way to change. The client can notify folder additions and removals, and a server opts in either by declaring the capability at initialization or by registering for the notification dynamically. The client can also be asked for the current list at any time, and the answer distinguishes two states that look alike: a null result means the editor has a single file open and no workspace at all, while an empty array means a workspace exists with no folders configured. Servers that collapse the two end up treating a single-file edit as a project of unconstrained scope, which typically means searching the entire filesystem for a symbol definition.

Folder semantics are not specified beyond identity. Whether a server starts one index per folder, one index over the union, or one index per folder with a merged view is an implementation choice. The union is easier and wrong for monorepos, where two folders can contain different versions of the same dependency and where the same relative import resolves differently in each; per-folder state is correct and costs memory proportional to the number of roots, since each root needs its own analysis and its own copy of any shared library model.

Removal is the case that breaks indexes. A folder that disappears from the list must be evicted from the server’s state, and any diagnostics published for documents inside it must be cleared rather than left on screen, since the clearing rules for the diagnostic channel apply per folder; a server that simply stops receiving notifications for those documents will otherwise keep reporting on them as if they still existed.

Configuration: pushed wholesale or pulled per scope

Two mechanisms carry settings, and they are not interchangeable. The push form is a notification carrying an opaque settings value that the server interprets; the client sends it at startup and whenever the user changes something, and the payload is usually the entire settings object for that server. The pull form is a request from the server naming configuration sections, optionally scoped to a specific resource URI, whose reply is one value per requested item.

Pull exists because of folders. With several roots, the same server instance serves documents that may legitimately have different settings: one folder disables a lint rule, another enables a stricter mode. A single pushed object has no place to express that difference, so a server relying on push either applies the same settings everywhere or reimplements folder resolution itself. A pull scoped to the document’s URI lets the client, which already knows which folder the document belongs to and which configuration files apply, do the resolution and hand back the effective value. The trade is a request per query unless the server caches, and a cache that must be invalidated on the configuration-change notification, which is why the push mechanism remains in use even when pull is available.

The capability negotiation decides which one is in play: a server that sends the configuration request to a client that did not advertise support receives a method-not-found error, and a client that only implements push never learns that the server wanted scoped values. Startup is the awkward moment, because the server needs settings to begin indexing and configuration requests can only be answered once the client is processing messages, so the usual arrangement is for the client to push an initial bundle and for the server to pull specifics later.

A related scoping mechanism lives outside the protocol entirely: per-directory configuration files, of the kind EditorConfig standardized, which the client resolves before starting a server or before answering a configuration request. Servers generally do not parse them, which keeps the layering clean but means a setting that affects analysis must be translated by the client into whatever section the server defines.

File watching for files never opened

A server that only learns about files through open and change notifications knows nothing about the rest of the project, and project-wide features such as cross-file definition, workspace symbols, and references require exactly that knowledge. The protocol therefore carries file events: created, changed, and deleted, each with a URI, delivered as notifications for the paths the client watches.

Registration determines the scope. A server can register glob patterns dynamically, with a watch kind per pattern, and the client is responsible for matching them against filesystem activity. The failure modes are all about mismatch between what the server believes it watches and what the client actually reports: a pattern that the client’s watcher does not implement, an excluded directory whose exclusions the server does not know about, or an event storm in a build directory that the server did not mean to include and cannot cheaply ignore. Deletions are the most damaging, because an index that keeps entries for deleted files produces definitions pointing at nothing, and the error surfaces much later as a navigation that lands on an empty buffer.

Ordering between the initial scan and the event stream is the other hazard. If the server begins indexing the filesystem and events arrive during the scan, an event can be applied to an index that has not yet read the file, and a naive implementation either loses the update or records the file twice. The usual fix is to buffer events during the scan and replay them afterwards, with the buffer keyed by URI so that repeated changes collapse.

Startup ordering and process-per-root

Servers start asynchronously and independently, so a client can have documents open before a server is ready. Nothing may be sent to a server before it has answered the initialize request, since the protocol forbids it and a strict server will reject the message, which means the client needs a queue of pending open notifications per server, flushed on initialization. The same applies to requests: a completion asked for during startup must either wait or be answered by another server, and waiting invisibly is worse than answering from the fallback.

Running one process per workspace folder, rather than one process aware of all folders, is the simpler correctness story: each process has a single root, a single configuration, and no need to separate state. It costs a process per root, each with its own memory and its own startup delay, and it makes cross-folder navigation impossible, since neither process knows the other exists. Running one process for all folders inverts both costs. The protocol does not decide between the two, and clients differ; the practical consequence is that a feature working in one editor and not in another is often a difference in this choice rather than in the server.

Cross-server cooperation is the genuine gap. Renaming a CSS class that appears in markup, or a field that appears in a query string, requires two servers to agree on an edit that neither can compute alone. The protocol provides no primitive for one server to query another, so clients approximate by applying one server’s edits and then re-requesting from the second against the new text, or by implementing the cross-language case in a server that understands both. Where that approximation fails, it fails as a partially applied rename: one side updated, the other still referring to the old name, and no error reported by either participant.

References

  1. Language Server Protocol Specification 3.17 — Microsoft
  2. Visual Studio Code API documentation — Microsoft
  3. EditorConfig — EditorConfig project