Skip to content

Implement an LspWorkspaceManager and modify how we fork documents in LSP.#57140

Merged
JoeRobich merged 10 commits intodotnet:mainfrom
dibarbet:lsp_workspace_manager
Oct 22, 2021
Merged

Implement an LspWorkspaceManager and modify how we fork documents in LSP.#57140
JoeRobich merged 10 commits intodotnet:mainfrom
dibarbet:lsp_workspace_manager

Conversation

@dibarbet
Copy link
Copy Markdown
Member

@dibarbet dibarbet commented Oct 14, 2021

This complexifies how we fork documents in LSP in order to hopefully simplify solution caching in LSP (as well as not throw away as much stuff each time we fork).

Currently, on every LSP request, if either the workspace or LSP text changed, we fork from the workspace's current solution with every open LSP document's text. This has become a problem for a couple reasons

  1. All open documents and projects their in have their version changed every time we fork from the workspace. This happens even if the project and document had 0 changes. Caching based on project (dependent) versions is not easily possible in this scenario.
  2. Every time we fork, we throw away the parsed trees for all the open documents, even if the document did not change.

The idea of this PR is to 'reverse' how we fork. Instead of forking all LSP documents when anything changes, we have an incremental LSP solution that we update based on LSP text changes. This solution is eventually consistent with the workspace.

  1. When LSP text changes come in, we only fork the relevant document.
  2. We listen to workspace events. When we receive an event that is not for an LSP open document, we delete our incremental LSP solution so that we can fork the workspace with all open LSP documents.

This is more complex, but has a few nice properties

  1. LSP didChange events only cause us to update the document that changed, not all open documents.
  2. Since we incrementally update our LSP documents, we only have to re-parse the document that changed.
  3. Since we incrementally update our LSP documents, project versions for other open documents remain unchanged.
  4. We are not reliant on the workspace being updated frequently (which it is not in VSCode) to do checksum diffing between LSP and the workspace.

Done

  1. added a bunch more tests
  2. manual testing in vscode ext
  3. manual testing in vs
  4. RPS - https://devdiv.visualstudio.com/DevDiv/_build/results?buildId=5352670&view=results Same regressions as we're seeing in our other insertions

Followup PRs

  1. Modify pull diagnostics 'nothing changed' caching to use project versions instead of solution crawler events - [LSP] Switch to project version caching instead of sln crawler events pull diagnostics #57380
  2. Use 1) to implement pull diagnostics in VSCode - proposed document pull diagnostics implementation #57518

1.  Manage finding what workspace + solution + document a uri maps to.
2.  Manage updating the lsp view of the world from LSP and workspace
    changes
@dibarbet dibarbet force-pushed the lsp_workspace_manager branch from 7ee3f99 to bfb5546 Compare October 16, 2021 02:57
@dibarbet dibarbet force-pushed the lsp_workspace_manager branch 2 times, most recently from affac5c to 135a766 Compare October 16, 2021 05:21
… and update lspworkspaceregistrationservice to be mef exported
@dibarbet dibarbet force-pushed the lsp_workspace_manager branch from 135a766 to 02e9302 Compare October 16, 2021 05:24
public ImmutableArray<Workspace> GetAllRegistrations()
=> _registrations;

public void Register(Workspace workspace)
Copy link
Copy Markdown
Member Author

@dibarbet dibarbet Oct 16, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the old implementation of ILspWorkspaceRegistrationService ended up not being useful as I needed to have events fired when workspaces were registered.

Now vscode just implements this type telling us what their host workspace is. we have an event listener that listens for workspace creation and just registers it automatically. this also helps with registering workspaces like the metadata as source workspace that are created dynamically by us, since the vscode extension has no access to it.

Comment thread src/Features/LanguageServer/Protocol/Workspaces/LspWorkspaceManager.cs Outdated
Comment thread src/Features/LanguageServer/Protocol/Handler/RequestContext.cs
Comment thread src/Features/LanguageServer/Protocol/Handler/RequestContext.cs
Comment thread src/Features/LanguageServer/Protocol/Handler/RequestContext.cs
Comment thread src/Features/LanguageServer/Protocol/Handler/RequestContext.cs

return solution;
_documentChangeTracker.UpdateTrackedDocument(uri, changedText);
_lspWorkspaceManager.UpdateLspDocument(uri, changedText);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

given the pair of htese, is it possible that _lspWorkspaceManager should itself own the _DocumentChangeTracker?

Copy link
Copy Markdown
Member Author

@dibarbet dibarbet Oct 18, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think the lspWorkspaceManager can own the IDocumentChangeTracker itself - specifically there are two different IDocumentChangeTrackers given to the context depending on if the work item can mutate or not mutate (if not, it gets a NoOp that throws on updating documents).

However, it might make sense to make the LSpWorkspaceManager implement IDocumentChangeTracker for mutating requests, so then we just have a call to _documentChangeTracker.UpdateTrackedDocument. I'll see if that is cleaner.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So just took a quick look. If I do anything here it will be in a followup. But I kind of like that these are separate. The document change tracker is specifically very simple and only cares about tracking changes to LSP text. It doesn't care about where that text belongs. So it makes sense to me to leave it as a separate component.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that works for me. thanks for looking.

Comment thread src/Features/LanguageServer/Protocol/Handler/RequestContext.cs Outdated
Comment thread src/Features/LanguageServer/Protocol/Handler/RequestExecutionQueue.QueueItem.cs Outdated
Comment thread src/Features/LanguageServer/Protocol/Workspaces/LspWorkspaceManager.cs Outdated
Comment thread src/Features/LanguageServer/Protocol/Workspaces/LspWorkspaceManager.cs Outdated
Comment thread src/Features/LanguageServer/Protocol/Workspaces/LspWorkspaceManager.cs Outdated
Comment thread src/Features/LanguageServer/Protocol/Workspaces/LspWorkspaceManager.cs Outdated
Comment thread src/Features/LanguageServer/Protocol/Workspaces/LspWorkspaceManager.cs Outdated
Comment thread src/Features/LanguageServer/Protocol/Workspaces/LspWorkspaceManager.cs Outdated
Comment thread src/Features/LanguageServer/Protocol/Workspaces/LspWorkspaceManager.cs Outdated
Comment thread src/Features/LanguageServer/Protocol/Workspaces/LspWorkspaceManager.cs Outdated
Comment thread src/Features/LanguageServer/Protocol/Workspaces/LspWorkspaceManager.cs Outdated
@dibarbet dibarbet marked this pull request as ready for review October 20, 2021 23:14
@dibarbet dibarbet requested a review from a team as a code owner October 20, 2021 23:14

// If there was an in progress work item that failed in the queue logic, set the result of the queue item
// to the exception so that it bubbles back to the caller.
inProgressWorkItem?.HandleQueueFailure(e);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we clear the inProgressWork item after its been processed? the dequeue of the next work item could wait indefinitely, so really its not the in progress work item, its the "last started work item". Also for non-mutating requests I'm not sure "in progress" makes any sense anyway, as they can overlap.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah I'll be slightly re-working this as part of #57221


_lspWorkspaceRegistrationService = lspWorkspaceRegistrationService;
lspWorkspaceRegistrationService.WorkspaceRegistered += OnWorkspaceRegistered;
// This server may have been started after workspaces were registered, we need to ensure we know about them.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's unlikely, but there is a race here right, where OnWorkspaceRegistered could throw if it gets an event fired while this next loop is running?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yup this is true, I can move after the population - will track doing that along with this followup #57276

Copy link
Copy Markdown
Member

@davidwengier davidwengier left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 to Cyrus's thoughts about the workspace manager owning the document tracking.

Comment on lines +25 to +31
// The lsp misc files workspace has the MiscellaneousFiles workspace kind,
// but we don't actually want to mark it as a registered workspace in VS since we
// prefer the actual MiscellaneousFilesWorkspace.
if (workspace is LspMiscellaneousFilesWorkspace)
{
return;
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does this have to be in the VS specific implementation? Because we don't want it registered anywhere, right? Even other hosts?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct!

@JoeRobich
Copy link
Copy Markdown
Member

Integration test failure is known. Other jobs are passing. Merging.

@JoeRobich JoeRobich merged commit a8a030c into dotnet:main Oct 22, 2021
@ghost ghost added this to the Next milestone Oct 22, 2021
@dibarbet dibarbet deleted the lsp_workspace_manager branch October 22, 2021 00:07
{
var oldSolution = this.CurrentSolution;
var newSolution = this.SetCurrentSolution(oldSolution.WithDocumentText(documentId, text));
var newSolution = this.SetCurrentSolution(solution);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was this supposed to be something else? Odd we set it even though nothing changed?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nope - the solution here is the solution that contains the new document (with documentId)

Copy link
Copy Markdown
Member

@jasonmalinowski jasonmalinowski left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It merged while in the middle of review, so some comments. Worried there might be some bugs around additional/.editorconfigs that might be bad.

internal partial class RequestExecutionQueue
{
/// <summary>
/// Associates LSP document URIs with the roslyn source text containing the LSP document text.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be useful to revise this with a reference to didChange or something to make it clearer this is where it's being accumulated.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will be fixed with #57276

Comment on lines +61 to +65
private readonly ILspLogger _logger;
private readonly IDocumentChangeTracker _documentChangeTracker;
private readonly LspMiscellaneousFilesWorkspace? _lspMiscellaneousFilesWorkspace;
private readonly RequestTelemetryLogger _requestTelemetryLogger;
private readonly LspWorkspaceRegistrationService _lspWorkspaceRegistrationService;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: sort this along with the constructor?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would be nice if we had a style rule + codefix - I never know what we want. will update

// Ensure we have the latest lsp solutions
var updatedSolutions = ComputeIncrementalLspSolutions_CalledUnderLock();

var hostWorkspaceSolution = updatedSolutions.FirstOrDefault(s => s.Workspace.Kind == _hostWorkspaceKind);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's odd to me here that we're filtering on kind directly, as opposed to just asking the LspWorkspaceRegistration service directly what the workspace is. Since it's already overloaded and giving us the host kind, why not just have that method return the workspace object directly and this just becomes SingleOrDefault(s => s.Workspace == _registrationService.GetHostWorkspace()) or something? You're still holding onto the registration service regardless.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Filtering on kind over workspace is intentional. It is very possible for a workspace to get registered later. For example, in the snappy work that Cyrus was working on, the VS workspace might get created later after other workspaces have been created and registered. Of course we could enforce the creation of the host workspace upfront, but not sure if that is necessarily better

// The tracked document might not be a part of this solution.
if (documentIds.Any())
{
solution = solution.WithDocumentText(documentIds, text);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this going to support changes to .editorconfig or additional files?

if (incrementalSolution == null)
{
// We have no incremental lsp solution, create a new one forked from the workspace with LSP tracked documents.
var newIncrementalSolution = GetSolutionWithReplacedDocuments(workspace.CurrentSolution, _documentChangeTracker.GetTrackedDocuments());
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason we're re-creating these all at once, versus just creating one the next time an LSP request comes in for a given workspace on demand?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes - on open we also check to see if a file exists in the registered workspaces' solution so that we know if we need to add to misc.

So rather than having two different ways of looking for documents, we just fork on open. Which I don't think will be an issue since

  1. open isn't that frequent of an operation
  2. the set of open documents is generally small, as well as the set of workspaces we need to fork. Generally it is 1 (VS workspace), sometimes it might be 2 (if a misc file is open).
  3. we're going to fork almost immediately since the semantic tokens / polling requests start on open.


// Update all the documents that have a matching uri with the new source text and store as our new incremental solution.
// We can have multiple documents with the same URI (e.g. linked documents).
var solution = GetSolutionWithReplacedDocuments(findDocumentResult.First().Project.Solution, ImmutableArray.Create((uri, newSourceText)));
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if more than one Workspace has a document with the same URI? There could be short windows in time where it happens...

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it is possible, especially with the LSP misc files workspace. We take the first found approach (always searching lsp misc last). The documents returned here though will always be from a single workspace.


static bool IsDocumentTrackedByLsp(DocumentId changedDocumentId, Solution newWorkspaceSolution, IDocumentChangeTracker documentChangeTracker)
{
var changedDocument = newWorkspaceSolution.GetRequiredDocument(changedDocumentId);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If changedDocumentId is an additional file or .editorconfig, won't this throw? You're calling it in those cases.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, this will throw. I will update this to only look at normal documents since we do not support modifications to non C#/VB/F# files yet.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch!

_workspaceToLspSolution[workspace] = null;
}

static bool IsDocumentTrackedByLsp(DocumentId changedDocumentId, Solution newWorkspaceSolution, IDocumentChangeTracker documentChangeTracker)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inline? Appears to only be called in one place?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I personally prefer it like this, makes it easier to read the intention of the code with a name, even if it is used once :)

{
lock (_gate)
{
var updatedSolutions = ResetIncrementalLspSolutions_CalledUnderLock();
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will this reset and recompute even if we're not impacted by the file at all? i.e. should we do the if check on the next line before actually doing the reset?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As in #57140 (comment)

I think we could consider doing that if it becomes a problem. But the set of workspaces is small and in most cases will just be the host workspace, so resetting all isn't necessarily an issue

/// should be called from a mutating request handler. See <see cref="RequestExecutionQueue"/> for more details.
/// </summary>
internal class DocumentChangeTracker : IWorkspaceService, IDocumentChangeTracker
internal class DocumentChangeTracker : IDocumentChangeTracker
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is now a pretty thin wrapper around a Dictionary -- do we need it? Is it easier if this just gets rolled the LspWorkspaceManager? It otherwise seems anybody has to call the open/close/update methods on both this and the manager.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(what I don't like is it's not clear to me if the request queue can call them in either order or if it must call the matching methods on one component and then the other which makes me wonder if these are really as independent as we hope they might be)

Copy link
Copy Markdown
Member Author

@dibarbet dibarbet Oct 22, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yup, will have a PR out that changes this with #57276

Comment on lines +189 to +191
// Trigger a fork of all workspaces, the LSP document text may not be correct anymore and this document
// may have been removed / moved to a different workspace.
_ = ResetIncrementalLspSolutions_CalledUnderLock();
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to other comment: should this only reset impacted documents?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants