Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

No more project.razor.bin files (in VS, at least) #10475

Merged
merged 45 commits into from
Jun 13, 2024

Conversation

DustinCampbell
Copy link
Member

@DustinCampbell DustinCampbell commented Jun 12, 2024

This is a pretty substantial pull request, so I'm getting it out there for review. I have not fully tested the change with VS Code, but Visual Studio is working ok.

This change completely refreshes all of the machinery around how RazorProjectInfo instances are passed from the Razor language client to the Razor language server. This is generally a complicated process with a lot of potential for reliability issues. The language client is expected to serialize a RazorProjectInfo instance representing a project update to a known location within the project's folder on disk. The language server watches for writes to that file (the dreaded project.razor.bin file, deserializes it and updates the server's project system.

Here's how things work after this change:

  • There is a new IRazorProjectInfoDriver abstraction that is optionally passed into RazorLanguageServer when it is created.
  • The language server connects an IRazorProjectInfoListener to the driver and is notified of project updates and removes. For an update, the listener is provided a RazorProjectInfo instance. For a remove, it's just given a ProjectKey.
  • In the language server, RazorProjectService provides the IRazorProjectInfoListener implementation. This results in a much simpler system with less machinery, allowing several "file detectors" and "state syncrhronizers" to be deleted.
  • For Visual Studio, an IRazorProjectInfoDriver is provided to the language server performs no serialization/deserialization of RazorProjectInfo instances. Instead, the language client and server just shared the same RazorProjectInfo instances. I'm expecting this to lead to performance and memory allocation wins.
  • For Visual Studio Code, no IRazorProjectInfoDriver implementation is provided. So, the language server falls back to a special FileWatcherBasedRazorProjectInfoDriver implementation that watches the workspace folder. The RazorWorkspaceListener in ExernalAcess.RoslynWorkspace still serializes RazorProjectInfo instances for VS Code. So, that should work as it did before, though I still need to test it. (I'd happily accept any help on this.)

Significant deletions from the language server in this change:

  • ProjectConfigurationFileChangeDetector
  • ProjectConfigurationFileChangeEventArgs
  • ProjectConfigurationStateSynchronizer
  • MonitorProjectConfigurationFilePathEndpoint
  • ProjectConfigurationStateManager
  • ProjectInfoEndpoint
  • RazorProjectInfoDeserializer

Significant deletions from VS in this change:

  • ProejctConfigurationFilePathChangedEventsArgs
  • ProjectConfigurationFilePathStore
  • RazorProjectInfoEndpointPublisher
  • RazorProjectInfoFileSerializer
  • RazorProjectInfoPublisher

Razor-CI for PR Validation: https://dev.azure.com/dnceng/internal/_build/results?buildId=2473027&view=results

Since the lifetime of the `RazorProjectInfoDriver` is the same as the language service, we let our language server client create and initialize it during activation. This means that the driver no longer needs to check if `IsLSPEditorAvailable()` since its constructed by the LSP editor's language client.
As part of this change, I've made `RazorProjectService` own the misc files project, rather than `SnapshotResolver`, which fixes a potential issue with initialization order.
This is no longer used by Visual Studio and VS Code never used it.
@DustinCampbell DustinCampbell requested a review from a team as a code owner June 12, 2024 00:56
Copy link
Contributor

@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.

Initialization seems a bit odd to me. The RazorProjectService initialization is tied to the initialization of another service, which is indirectly tied to IOnInitialize, but it seems like if the project server adds itself as a listener in IOnInitialize then the driver could start pushing work in IOnInitialized and nothing would need specific initialization at all. The file watching driver would know that the workspace folder is available, because IOnInitialize is always before IOnInitialized, and only one thing (the driver) would be doing the initial push for the current state of project infos.

{
// If the language server was not created with an IRazorProjectInfoDriver,
// fall back to a FileWatcher-base driver.
services.AddSingleton<IRazorProjectInfoDriver, FileWatcherBasedRazorProjectInfoDriver>();
Copy link
Contributor

Choose a reason for hiding this comment

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

I wonder if the FileWatcherBasedRazorProjectInfoDriver should be constructed by rzls, and passed in to here?

Copy link
Member Author

@DustinCampbell DustinCampbell Jun 12, 2024

Choose a reason for hiding this comment

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

I had wanted to do that, but it needs the IWorkspaceRootPathProvider, which is tied to the CapabilitiesManager. I'll take another look to see if I can do something better here.

Copy link
Member Author

Choose a reason for hiding this comment

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

I took another look. Unfortunately, we just don't know what the root path is when rzls is constructed. Since FileWatcherBasedRazorProjectInfoDriver is dependent on IWorkspaceRootPathProvider, and that's dependent on the CapabilitiesManager, it really needs to be constructed by DI. Maybe we can improve this later, but I think it's probably OK for now.


protected override async Task InitializeAsync(CancellationToken cancellationToken)
{
var workspaceDirectoryPath = await _workspaceRootPathProvider.GetRootPathAsync(cancellationToken).ConfigureAwait(false);
Copy link
Contributor

Choose a reason for hiding this comment

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

I think its a bit odd that we start this up immediately, and then immediately wait for IOnInitialize indirectly through the workspace path provider. I wonder if this self-initialization should/could be replaced with this implementing IOnIntiailized?

Copy link
Member Author

Choose a reason for hiding this comment

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

Initially, I tried using IOnInitialized and ran into problems with ordering. Since IOnInitialized offers no guarantees about what order services will be initialized in, it's easy to get into trouble when there are dependencies between services that are all marked for initialization.

Copy link
Member Author

Choose a reason for hiding this comment

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

Note that there's no such interface as IOnInitialize. I just want to be sure that we're getting the terminology right. So, I'm stating all of this to make my logic clear:

  1. There are two LSP messages: "initialize" and "initialized". The first one happens before the second one.
  2. The first LSP message sent is "initialize". When received by RazorInitializeEndpoint, CapbilitiesManager.SetInitializeParams(...) is called, making the RootPath and RootUri values available.
  3. The IWorkspaceRootPathProvider.GetRootAsync(...) implementation on CapabilitiesManager spins until the InitializeParams become available. Once available, it returns the root path value.
  4. The next LSP message should be "initialized". When received by RazorInitializedEndpoint, it loops through all IOnInitialized LSP services and calls OnInitializedAsync.

In my opinion, it's OK to await IWorkspaceRootPathProvider.GetRootAsync(...) here. This happens before "initialized" and allows FileWatcherBasedRazorProjectInfoDriver to start loading projects early, so it's slightly more likely that the server will be ready to provide project information for the first LSP message that needs it.

Copy link
Contributor

Choose a reason for hiding this comment

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

Note that there's no such interface as IOnInitialize

I think that makes all of my points invalid. I'm not sure where I got the idea from.

Copy link
Member Author

Choose a reason for hiding this comment

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

No worries! Another reason I clarified was to make sure that it wasn't just a typo. It could have just been IOnInitialized with the 'd' left off. 😄

await _projectInfoDriver.WaitForInitializationAsync().ConfigureAwait(false);

// Register ourselves as a listener to the project driver.
_projectInfoDriver.AddListener(this);
Copy link
Contributor

Choose a reason for hiding this comment

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

As per a previous comment, the initialization is odd here. This initialization happens immediately, it them initializes the project info driver, which goes through all of the current projects and starts a task for them, then it adds itself as a listener, then this goes through the current projects and updates them. Seems like both this and the driver are trying to inialize a starting set of projects, and if this AddListener call was moved above the WaitForInitializationAsync call then the below loop could be removed?

Copy link
Member Author

Choose a reason for hiding this comment

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

In VS, the driver is "hot" and starts populating immediately. If AddListener is moved above WaitForInitializationAsync, I believe there's a potential race condition where incoming project updates could be processed earlier and then rolled back by the loop. For that reason, AbstractRazorProjectInfoDriver.AddListener() throws if called before initialization is finished.

@davidwengier
Copy link
Contributor

The RazorWorkspaceListener in ExernalAcess.RoslynWorkspace still serializes RazorProjectInfo instances for VS Code. So, that should work as it did before, though I still need to test it. (I'd happily accept any help on this.)

Since it hasn't changed, testing this would really just be using the C# extension as is, but specifying the Razor langauge server path to be the rzls built from this PR.

@DustinCampbell
Copy link
Member Author

DustinCampbell commented Jun 12, 2024

Initialization seems a bit odd to me. The RazorProjectService initialization is tied to the initialization of another service, which is indirectly tied to IOnInitialize, but it seems like if the project server adds itself as a listener in IOnInitialize then the driver could start pushing work in IOnInitialized and nothing would need specific initialization at all. The file watching driver would know that the workspace folder is available, because IOnInitialize is always before IOnInitialized, and only one thing (the driver) would be doing the initial push for the current state of project infos.

Thanks for the review! I definitely understand your concern. I'm probably being a bit overprotective around initialization of RazorProjectService to avoid bad start-up state. I'll take another look and make this better. In general, I think I'd like to avoid going through IOnInitialized at all. Unlike the file watcher case, the VS driver should start "hot", which is what I was initially going for. I'm definitely not trying to optimize for the VS Code case, though I'm very nearly inclined to try and implement MemoryMappedFile approach we discussed last week, just to get rid of the file watchers. 😄

`RazorProjectService` doesn't need to force itself to be initialized when the language server is initialized. It's registered as an `IRazorStartupService`, so it starts initializing right away. Since all `RazorProjectService` public entry points await initialization, we don't need to force it to finish initializing in `OnInitialized`. This is especially true if the driver itself implicitly waits for `OnInitialized`.
For types that have a `CancellationTokenSource` that is triggered during `Dispose()`, we should check to see if it has already been cancelled to avoid disposing twice. Implicitly, there's a race here if there are multiple threads calling `DIspose()`, but these are all handled by the DI containers we use.
Copy link
Contributor

@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.

Thought about the initialization more, and I think that its not worth blocking on, and I suspect not possible to actually change how I suggested. I got it in my head that there was a way of running services when both initialize and initialized messages were received, but as there isn't, the point is largely moot. After sleeping on it, I'm also not sure how I expect it to work anyway, since the driver would have to be activated on initialized, but its not owned by the language server which recieves that message, nor can it be.

`AbstractRazorProjectInfoDriver`can't call `InitializeAsync(...)` in its constructor because the driver will only be partially constructed. To address that, add a `StartInitialization` method that drivers call from their constructor. This will kick off initialization and set the result of a `TaskCompletionSource` when it finishes.
Instead of spinning, use a TaskCompletionSource to track initialization and an AsyncLazy for the root path.
@DustinCampbell DustinCampbell merged commit 8f1aa3e into dotnet:main Jun 13, 2024
12 checks passed
@DustinCampbell DustinCampbell deleted the binless branch June 13, 2024 23:51
@dotnet-policy-service dotnet-policy-service bot added this to the Next milestone Jun 13, 2024
ryzngard added a commit to dotnet/roslyn that referenced this pull request Jun 17, 2024
Two major changes being introduced:

dotnet/razor#10480 moves our project change listener to AsyncBatchingWorkQueue to improve stability
dotnet/razor#10475 rearchitects our project syncing. Expected to have no impact on VS Code
@jjonescz jjonescz modified the milestones: Next, 17.11 Preview 3 Jun 24, 2024
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.

3 participants