[ty] Support multiple workspace folders in a single ty LSP server instance#22953
[ty] Support multiple workspace folders in a single ty LSP server instance#22953BurntSushi merged 8 commits intomainfrom
Conversation
|
Recording of what ty-single-workspace-only.mp4A recording of what it's like to support multiple workspace folders, but without support for ty-db-per-workspace-no-change-support.mp4And finally, full support: 2026-01-29T14.54.42-05.00.mp4 |
dhruvmanila
left a comment
There was a problem hiding this comment.
This is great!
I'm a bit confused with how this works with "workspace" diagnostic mode.
| // Last setting wins. | ||
| global_options = Some( | ||
| self.initialization_options | ||
| .options | ||
| .global | ||
| .clone() | ||
| .combine(options.global), | ||
| ); |
There was a problem hiding this comment.
I think this makes sense but just to confirm my understanding based on reading the above comment:
Considering a VS Code workspace containing two workspace folders and
- User has defined some settings in their global
settings.json(also known as User Settings) - User has also defined per-project settings in
.vscode/settings.jsonfor both the projects
When a server asks for configuration via workspace/configuration, VS Code should resolve the configuration values considering all the above locations. Given the restriction that we set on global settings in VS Code (the "window" scope), the values for all of the global settings are going to be the same for each workspace. This means that we don't need to combine them for each workspace and we can choose the last one.
Am I interpreting this correctly?
There was a problem hiding this comment.
Yes, your interpretation of what VS Code does matches what I've observed and is part of the motivation for doing this.
I think there's another side to the motivation here: what if we could get different global settings from each workspace folder? Well in that case, either way we slice it, we're going to get it wrong for at least one of the workspace folders because we can only have one value for each global setting. So I make the additional argument here that in that scenario, we shouldn't try to combine global settings across workspace folders and instead just use the most recent settings transmitted from the client.
There was a problem hiding this comment.
Yeah, that seems like a reasonable argument 👍
| Action::InitializeWorkspaces(workspaces_with_options) => { | ||
| self.session | ||
| .initialize_workspace_folders(workspaces_with_options, &client); | ||
| .initialize_workspace_folders(&client, workspaces_with_options); |
There was a problem hiding this comment.
nit: it might be useful to rename the action to be similar in line with the invoked function, so Action::InitializeWorkspaceFolders
There was a problem hiding this comment.
Yeah I was trying to avoid doing too much renaming. I would like to do a follow-up PR where I do a more consistent rename from "workspace" to "workspace folder."
crates/ty_server/src/server/api/notifications/did_change_workspace_folders.rs
Outdated
Show resolved
Hide resolved
| // Now remove one of the workspaces and assert that the | ||
| // diagnostics are now limited only to `root1`. | ||
| server.change_workspace_folders([], [root2]); | ||
| // We don't need to wait for workspace initialization | ||
| // since we are only removing a workspace. That is, the | ||
| // server is not expected to send a `workspace/configuration` | ||
| // request. | ||
|
|
||
| let workspace_diagnostics = server.workspace_diagnostic_request(None, None); | ||
| assert_snapshot!( | ||
| condensed_workspace_diagnostic_snapshot(workspace_diagnostics), @r" | ||
| file://<temp_dir>/root1/main.py | ||
| 0:0..0:14[ERROR]: Name `does_not_exist` used when not defined | ||
| " | ||
| ); |
There was a problem hiding this comment.
Oh, this is interesting, I'm not sure how this is working when the logic for removing the workspace folders only clears diagnostics for open files? I'm probably missing something 😅
There was a problem hiding this comment.
I think for this test specifically, it works because of this:
ruff/crates/ty_server/src/server/api/requests/workspace_diagnostic.rs
Lines 135 to 141 in bc0427e
Since the workspace folder was removed, so to was its project database. So it's never actually checked and then isn't included in the response. The server itself doesn't seem to keep around state of diagnostics present on the client, so this all works.
I think the fact that the workspace diagnostics get cleared in VS Code is perhaps a little more surprising? I think there's an expectation that a diagnostic sticks around until it has been cleared. But it seems like the VS Code client is doing some clearing for us perhaps.
|
|
||
| server.change_workspace_folders([], [root1]); | ||
|
|
||
| let workspace_diagnostics = get_expected_empty_workspace_diagnostics_and_shutdown(server); |
There was a problem hiding this comment.
This might be related to #17944 so I wouldn't look too deep into it.
There was a problem hiding this comment.
Yeah it looks like I can make VS Code peg the CPU even on main by doing the same thing. Basically, opening a file with no workspace folders added.
|
It might be useful to try this out on the pyx codebase, maybe something that @zsol could help with? |
This is a small code change meant to implement a simpler semantic in preparation for supporting multiple workspace folders. Previously, we were combining global options across all workspace folders. In this change, we now only combine global options from initialization and each workspace folder independently. We then take the *most recent* combination and use that for our global settings. It's not clear that this actually changes anything in practice. I've been unable to make VS Code send different LSP configurations for each workspace folder within a single workspace. (It's plausible our ty VS Code extension is actually doing this.)
… server instance This is pretty much all we need to support multiple workspace folders. Previously, we were going out of our way to force only allowing a single workspace. This commit just removes that restriction. This works by making each workspace folder use its own project state (i.e., salsa database). We chose to go this route for now since it's the simplest to implement and unlocks folks using multi-root workspaces inside of VS Code. We hadn't done this before because we were undecided on how this would impact memory usage. In particular, this will result in a separate auto-import cache for each workspace folder. We live with this limitation for now. This does not add support for responding to `workspace/didChangeWorkspaceFolders` notifications. We'll do that in a subsequent commit.
I find this is a bit easier to reason about.
Previously, we only kept a count of the number of workspaces initialized, but we had no way of knowing *which* workspaces were initialized. Knowing which are initialized is useful for avoiding initializing a workspace again. This is relevant because previously we only called `initialize_workspace_folder` at actual initialization time. But soon we're going to want to call it at arbitrary points in response to `workspace/didChangeWorkspaceFolders` notifications. Making initialization a no-op if a workspace is already initialized seems more flexible and harder to misuse. With that said, presumably, LSP clients won't try to re-initialize workspace folders. This does change a couple checks that were previously constant time to be linear over the number of workspace folders. I think this is fine given that the unit of work here is tiny and the number of workspace folders is not expected to be enormous. (The point at which a linear scan over all workspaces is problematic likely means we have much bigger problems.)
This basically moves the logic out of `Server` and into `Session`. Moreover, instead of iterating over all registered workspace folders, it only iterates over registered workspace folders that haven't yet been initialized. This in turn makes the routine idempotent and easier to call (for when we register new workspace folders).
This lifts the already existing helper routine to a place where more things can use it. We'll want this for when we remove a workspace folder.
bd2436a to
6fc5f32
Compare
…cations With all of the refactoring done in previous commits, this mostly comes down to adding a new notification type and a new method for removing a workspace folder (which requires some clean-up).
This tests various permutations for adding and removing workspace folders both during and after server initialization. We also add some tests checking the behavior of settings as passed from the LSP client. We make use of workspace diagnostics here as a way to check whether the server is "aware" of a workspace folder or not.
6fc5f32 to
e284cc8
Compare
With astral-sh/ruff#22953 merged, the ty server now supports multiple workspace folders. This means we can deal with workspace settings diverging between folders. This change specifically only applies to workspace settings. The other global settings are left with a scope of `window`, since the ty server expects there to be only one value for global settings. Closes #319
With astral-sh/ruff#22953 merged, the ty server now supports multiple workspace folders. This means we can deal with workspace settings diverging between folders. This change specifically only applies to workspace settings. The other global settings are left with a scope of `window`, since the ty server expects there to be only one value for global settings. Closes #319


This is also known as "multi-root" workspace support. Or more
imprecisely, "multiple workspace" support. (The LSP protocol doesn't
actually have a concept that one can call "multiple workspaces." There
is only ever one workspace, but that workspace may have multiple root
folders. i.e., Workspace folders.)
The approach taken here is to use a distinct Salsa database for each
workspace folder. It's not clear if we still stick with this approach
going forward. In particular, it's likely to exacerbate memory usage
in contexts where there are many open workspace folders. There are
likely some ways to claw back memory usage, for example, by sharing one
auto-import index instead of using an auto-import index for each Salsa
database. Regardless, this seems to be how other LSP servers (like
pyright) handle multiple workspace folders. So this is where we start.
This PR includes support for adding/removing workspace folders via the
workspace/didChangeWorkspaceFoldersnotification.I've added a bunch of end-to-end tests covering multiple workspace
folder support, including handling of settings. (This PR makes a change
to how global settings are extracted in the presence of multiple
workspace folders. The change is made in its own commit with more
details.)
Reviewers are encouraged to read this PR commit-by-commit. They may
find it helpful to skip to reading the last commit, which includes
has a bunch of end-to-end tests. It might be helpful to see what the
intended observable behavior is.
Closes astral-sh/ty#89