Skip to content

Add opt-in support for one clangd instance per project - #1018

Open
planger wants to merge 11 commits into
clangd:masterfrom
eclipse-cdt-cloud:upstream-multi-project
Open

planger wants to merge 11 commits into
clangd:masterfrom
eclipse-cdt-cloud:upstream-multi-project

Conversation

@planger

@planger planger commented Nov 9, 2026 •

Copy link
Copy Markdown

So far vscode-clangd runs a single clangd for all workspace folders. In workspaces with several independent projects, this mixes their indexes. For instance, go to definition and find references jump into the wrong project if several projects define a function with the same signature, and a folder can't use its own clangd arguments (see #498 and clangd/clangd#1400).

With this change, the new setting clangd.enablePerFolderServer (off by default) runs a separate clangd for each project. By default, every workspace folder is a project, and its clangd starts once one of its files is shown in an editor. The settings that concern a single clangd instance (clangd.arguments, clangd.fallbackFlags, etc.) become resource-scoped, so each folder can configure its own. Commands, views and the server commands clangd.applyFix and clangd.applyTweak are registered only once and go to the client that serves the document. Also, getApi(2) gives extensions access to the clients and lets build system extensions contribute their own projects, e.g. one per build directory. With the setting off, the extension behaves as before, with a few small differences:

  • API v1's languageClient is undefined while clangd is stopped (e.g. after clangd.shutdown), where it used to be the stopped client.
  • Restart and shutdown do nothing while the session is still starting, not only while the client is starting, so that a restart can't create a second client concurrently.
  • The commands that need a running clangd say "clangd is not running for this file" instead of failing with a request error, and the AST, memory usage and inlay hints commands are disabled while clangd isn't running, like the type hierarchy already was.
  • Failed workspace/executeCommand requests (e.g. applyTweak) are shown to the user, as intended. So far, swapped parameters in handleFailedRequest prevented that, which a separate commit fixes.

The commits can be reviewed one by one. The setting name, the resource-scoped settings and API v2 follow #810 by @daandemeyer, and the corresponding commits list Daan as co-author. Unlike #810, clangd.enable, clangd.checkUpdates and clangd.semanticHighlighting stay window-scoped, and clangd is located (and installed or updated) once for all projects, so all projects use the same clangd binary.

To test it:

  1. Open a multi-root workspace with two folders, each with its own compile_commands.json and a function with the same signature.
  2. Add "clangd.enablePerFolderServer": true to the workspace settings.
  3. Open a file of each folder and observe how two output channels show up, clangd (<folder name>).
  4. Go to definition and find references on that function stay within the folder.
  5. Optionally, set clangd.arguments in the .vscode/settings.json of one folder and observe that only the clangd of that folder gets them.

Note that npm test didn't run any test so far: test/index.ts looked for *.test.ts in out/ and didn't wait for mocha to finish. The first commit fixes that, together with the two InactiveRegionsFeature tests that have been failing unnoticed since. I'm happy to move it to a separate PR.

The same functionality is also available in our fork (eclipse-cdt-cloud#11). However, we'd much prefer to have it upstream instead of maintaining a fork. WDYT?

planger and others added 3 commits November 9, 2026 13:33
test/index.ts looked for *.test.ts files in out/, which only contains the
compiled *.test.js files, so no test ran. Also, run() returned before
mocha finished, so VS Code exited before any test could report a
failure.

Look for *.test.js and resolve run() once mocha is done, rejecting if
tests failed.

The two InactiveRegionsFeature tests have been failing unnoticed since the
decoration type is created only once the visible editors change: they
now create it before sending the notifications.
vscode-languageclient declares handleFailedRequest(type, token, error,
defaultValue, ...), but the override declared (type, error, token,
defaultValue). The error therefore arrived in the token parameter, so the
ResponseError check never matched and failed workspace/executeCommand
requests (e.g. applyTweak) were never reported to the user. The swapped
arguments were also passed on to the base implementation.

Use the library's parameter order and forward the optional
showNotification and throwOnCancel arguments.
Per-project clangd instances need to read settings in the scope of the
folder they serve, and ${workspaceFolder} must then refer to that
folder rather than to the first workspace folder.

config.get() takes an optional folder. With a folder, the value is read
from the folder's configuration scope, ${workspaceFolder} (and its
aliases) resolves to the folder's path and ${workspaceFolderBasename}
to its name. Without a folder, the substitutions are unchanged, and the
value is read for no specific resource (scope null): that gives the same
values as before, and VS Code expects it for resource-scoped settings,
which it otherwise reads with a warning in the log.

Co-authored-by: Daan De Meyer <daan.j.demeyer@gmail.com>
@planger
planger force-pushed the upstream-multi-project branch from d3410c8 to 6374428 Compare November 9, 2026 12:39
@planger
planger marked this pull request as ready for review November 9, 2026 12:49
planger and others added 8 commits November 9, 2026 14:24
The activation code owned a single ClangdContext, and the context ran the
installer and started its own client from the constructor. Supporting more
than one clangd instance needs a single owner for the session state, and a
context whose wiring is complete before its client starts.

Introduce ClangdContextManager, which runs install.activate, creates the
context and implements restart and shutdown. extension.ts registers the
commands and delegates to it, and the API reads the current client from
the manager. API v1 returns no client while clangd is stopped, where it
used to return the stopped client.

Restart and shutdown keep the guard of before: they do nothing while the
client is starting. They now also do nothing while the session itself
starts (install.activate and the creation of the context), where a
restart used to create a second context concurrently.

ClangdContext now takes the clangd path and its manager, exposes the
documentSelector it serves, and starts its client only from an explicit
startClient(). dispose() no longer calls stop() on a client that is
starting, which the language client rejects: it waits until the client
runs or failed to start, also when the client restarts after a crash,
and ignores stop() failures, which the client logs. A client disposed
while it starts still initializes its features; what they subscribe to
from then on is disposed right away.

Contexts are owned by the manager and are no longer pushed into the
extension's subscriptions on every restart.

API v1's languageClient is now undefined while there is no client, e.g.
after clangd.shutdown or while a restart reinstalls clangd, where it
used to be the stopped client.
Each feature registered its commands, tree views and the status bar item
while a context started its client, so a second context would fail with
duplicate command and view ids.

Split every feature into a UI part, registered once per session through
registerFeatureCommands(manager), which sits next to the list of
per-client activations in clangd-context.ts, and a per-client part that
stays a StaticFeature of the context. The UI part looks up the context
to act on through the manager:

- editor commands (AST, type hierarchy, switch header/source) use the
  context serving the document;
- the type hierarchy view remembers the client that produced its root
  and closes when that client's context goes away, also while its
  request is in flight;
- the memory usage view has one top-level node per client and leaves
  out clients whose request fails; if all fail, the command fails with
  the error, as before;
- the file status keeps the statuses per context;
- the legacy inlay hints provider uses the context's document selector.

The commands exist exactly while a session exists, as before. With the
single context of today, they behave as before, except when the client
has stopped for good (e.g. it crashed too often): the editor commands
and the memory usage command then say that clangd is not running
instead of failing with a request error.

Co-authored-by: Daan De Meyer <daan.j.demeyer@gmail.com>
Running one clangd per project needs a description of the projects and
their roots, independent of where the projects come from.

A ClangdProject is a root directory with an optional name, and a
ClangdProjectsChangeEvent lists added, removed and updated projects.

Provide the helpers the manager needs:

- uriContains() compares paths segment-wise (so /a/p does not contain
  /a/p2) and requires the same scheme and authority;
- projectKey() identifies a project by its root;
- both ignore case on Windows and macOS, as VS Code does when it
  matches the RelativePatterns of document selectors;
- projectLabel() names a project in the UI: its name, or its path if
  another project has the same name;
- normalizeProjects() drops projects that are not on disk, duplicates
  and nested projects; the outermost project wins, because document
  selectors can't exclude a sub-directory and a client receives every
  document below its root;
- diffProjects() reports added, removed and updated (renamed) projects.
Every clangd advertises the same executeCommandProvider commands
(clangd.applyFix and clangd.applyTweak). vscode-languageclient's
ExecuteCommandFeature registers each of them as a VS Code command, so a
second client fails to register them.

ServerCommandRegistry registers each command once and keeps the set of
clients that advertised it; the registration goes away with the last
of them. An invocation goes to the client serving the document named in
the arguments (TweakArgs.file for applyTweak, the first document of the
WorkspaceEdit for applyFix, see ClangdLSPServer.cpp), else to the client
of the active editor, else to the only owner. Failures are reported
through the client's handleFailedRequest().

SharedServerCommandsFeature moves a client's commands to the registry
in preInitialize(), before the built-in feature sees them. It replaces
executeCommandProvider in the capabilities copy handed to the features,
so the client's initializeResult still lists all commands. Once detached,
e.g. from a client that is disposed while it starts, it still hides the
commands from the built-in feature but no longer adds them to the
registry.
A single clangd serving all workspace folders can't use per-folder
compilation databases, arguments or working directories, and a crash or
a large index in one folder affects all of them.

With the new window-scoped setting clangd.enablePerFolderServer (default
false, changing it restarts clangd), the manager runs one clangd per
project, i.e. per workspace folder:

- install.activate runs once; the commands and views are registered
  once per session.
- A ProjectSet in project.ts follows the workspace folders, normalizes
  them (the outermost of nested folders wins) and reports the
  differences. The manager only reacts to them: removed projects lose
  their context and output channel, renamed ones restart their client,
  and added ones start for visible editors. A channel is disposed right
  away; the writes of its clients that are still stopping are dropped.
  The projects are kept across restarts and cleared when the default
  mode starts.
- A project's context is created lazily when one of its files is shown
  in a visible editor (initially and on onDidChangeVisibleTextEditors),
  not when a document is merely opened. The pending creation is stored
  per project key before anything is awaited, so a project gets one
  context, and a creation that completes after the project was removed,
  stopped or the session was torn down is disposed. A caller waiting for
  an abandoned creation gets the context of the one that superseded it.
- clangd.restart and clangd.shutdown take an optional Uri and then act
  on the owning project only; the config file watcher passes the
  project's root. A project shut down this way stays stopped until it is
  restarted. Without a Uri, all clients restart or stop. A restart
  recreates every context that existed before, also those without a
  visible file.
- In multi-project mode, nothing blocks a restart or shutdown of all
  clients: a context whose client is starting stops it once it has
  started, its server commands never reach the shared registry, and the
  session can be torn down while it starts. In the default mode, a
  starting session or client still blocks them, as before. A restart
  requested while the session starts or restarts is served by that
  start or restart, unless it was abandoned, e.g. by a shutdown, or
  clangd.enablePerFolderServer changed meanwhile; it then runs
  afterwards, so that two sessions never start at the same time.

Each project's client selects only the clangd documents below its root
through a RelativePattern, is locked to its workspace folder, runs in
the root directory with clangd.arguments (read in the folder scope),
and logs to its own channel "clangd (<name>)", or "clangd (<root path>)"
if another project has the same name. clangd.trace gets the project's name and
a hash of its root inserted before the extension (trace.json becomes
trace.<name>-<hash>.json), so that the instances don't write to the
same trace file, even if projects have the same name.

The server commands of all clients go through the shared registry, also
in the default mode, where the single client owns them; a mode change
therefore never registers a command twice.

The UI follows the project: the file status shows the project's label,
the memory usage view shows one node per project, clangd.projectConfig
opens the active project's .clangd, and API v1 and the file status'
"open output" command use the client of the active editor if it shows
a C/C++ file, else the one of the clangd editor focused last. The
context keys that enable the AST, memory usage, inlay hints and type
hierarchy commands are owned by the shared part of each feature and
true while any running client's server supports the feature; the
manager's new onDidChangeClients event tells when to recompute them.

Files outside all projects are not served; a command invoked on such a
file says so. With the setting off, the behavior is unchanged, except
that the context keys of the AST, memory usage and inlay hints
commands, like the one of the type hierarchy before, are false while
clangd is not running.

Co-authored-by: Daan De Meyer <daan.j.demeyer@gmail.com>
With clangd.enablePerFolderServer, every clangd instance belongs to a
workspace folder, but most of its settings were window-scoped and read
without a scope, so folders couldn't configure their clangd differently.

Move the settings that concern a single clangd instance to resource
scope and read them in the context's workspace folder: arguments,
fallbackFlags, trace, restartAfterCrash, onConfigChanged,
onConfigChangedForceEnable, enableCodeCompletion, enableHover,
serverCompletionRanking and inactiveRegions.*. The completion and hover
middleware read them in the folder of the client that serves the
document.

Settings of the binary and of the extension as a whole (enable,
checkUpdates, path, useScriptAsExecutable) and the deprecated
semanticHighlighting stay as they are.

In the default mode, the context has no folder, so the settings are read
for no specific resource (config.get passes the scope null), which gives
the values of before and logs no warning; VS Code ignores folder values
of resource-scoped settings read that way. The description of
clangd.enablePerFolderServer and docs/settings.md say so; the
descriptions of the moved settings are unchanged.

Co-authored-by: Daan De Meyer <daan.j.demeyer@gmail.com>
API v1 offers a single languageClient snapshot, which can't express the
clients of the multi-project mode. Extensions need to look up the client
serving a file, e.g. to send clangd's custom requests to it.

getApi(2) returns ClangdApiV2, a superset of the version 2 proposed in
clangd#810:

- getLanguageClient(uri?) returns the client serving uri, or without
  uri the one of the active editor if it shows a C/C++ file, else of the
  clangd editor focused last; in the default mode, the single client.
- getAllLanguageClients() returns all clients, and onDidChangeClients
  fires when a client is added or removed, or starts or stops.
- startLanguageClient(uri) starts the client serving uri if needed and
  resolves to it once it runs, or to undefined if there is none or it
  fails to start. It waits for a start or restart of the session in
  progress.
- multiProject tells whether clangd.enablePerFolderServer is in effect.

All of them return only clients that are starting or running: requests
to a starting client are delayed until it runs, and clients that have
stopped, e.g. after crashing too often, are left out.

getApi(1) is unchanged, and unknown versions still throw.

The declarations now refer to the vscode module, so @types/vscode
becomes a peer dependency of the API package.
The projects can only be the workspace folders. Build-system extensions
need to contribute projects that are sub-folders, e.g. sibling build
directories in one workspace folder, each with its own arguments.

API v2 gains:

- projects, getProject(uri), activeProject, onDidChangeActiveProject and
  onDidChangeProjects, which describe the projects; they are empty in
  the default mode, and kept while clangd restarts or is shut down, so a
  global restart reports no changes.
- registerProjectProvider(id, provider), which adds a source of
  projects. While any provider is registered, the ProjectSet takes the
  union of the results of all providers, normalized as before
  (outermost wins, non-file roots and duplicates are dropped), instead
  of the workspace folders. A union doesn't depend on the order in which
  providers activate. Registering and unregistering a provider, and its
  onDidChangeProjects, re-query the providers and apply the difference:
  a provider registered after the workspace-folder clients started
  replaces them once, and an updated project restarts only its client.
  Providers are kept but not queried in the default mode. A provider
  whose onDidChangeProjects throws is not registered.
- ClangdProject.arguments, passed to the project's clangd after
  clangd.arguments.

ClangdProject and ClangdProjectsChangeEvent move from project.ts to the
API declarations.

Querying the providers is asynchronous and not part of the session's
startup, so a slow provider never blocks restart or shutdown. If a
provider fails, doesn't respond within 10 seconds or returns something
other than a list of projects, its projects of the last successful query
are kept (none if there was none), which is logged: a transient failure,
e.g. during a build-system reconfigure, doesn't tear down its clients.
An answer after the 10 seconds is still applied, unless the provider was
queried again meanwhile: the provider has no reason to fire
onDidChangeProjects again. A query is cancelled when a newer one starts
or the session is torn down. startLanguageClient() waits for the
projects to be up to date.

Providers are never waited for during activation: they can only
register once the extension has returned its API.
@planger
planger force-pushed the upstream-multi-project branch from 6374428 to abd146a Compare November 9, 2026 13:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant