Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,11 @@ public void PublishCSharp(ProjectKey projectKey, string filePath, SourceText sou
{
HostDocumentFilePath = filePath,
ProjectKeyId = projectKey.Id,
Changes = textChanges.Select(static t => t.ToRazorTextChange()).ToArray(),
Changes = [.. textChanges.Select(static t => t.ToRazorTextChange())],
HostDocumentVersion = hostDocumentVersion,
PreviousHostDocumentVersion = previouslyPublishedData.HostDocumentVersion,
PreviousWasEmpty = previouslyPublishedData.SourceText.Length == 0,
Checksum = Convert.ToBase64String(sourceText.GetChecksum().ToArray()),
Checksum = Convert.ToBase64String([.. sourceText.GetChecksum()]),
ChecksumAlgorithm = sourceText.ChecksumAlgorithm,
SourceEncodingCodePage = sourceText.Encoding?.CodePage
};
Expand Down Expand Up @@ -145,10 +146,10 @@ public void PublishHtml(ProjectKey projectKey, string filePath, SourceText sourc
{
HostDocumentFilePath = filePath,
ProjectKeyId = projectKey.Id,
Changes = textChanges.Select(static t => t.ToRazorTextChange()).ToArray(),
Changes = [.. textChanges.Select(static t => t.ToRazorTextChange())],
HostDocumentVersion = hostDocumentVersion,
PreviousWasEmpty = previouslyPublishedData.SourceText.Length == 0,
Checksum = Convert.ToBase64String(sourceText.GetChecksum().ToArray()),
Checksum = Convert.ToBase64String([.. sourceText.GetChecksum()]),
ChecksumAlgorithm = sourceText.ChecksumAlgorithm,
SourceEncodingCodePage = sourceText.Encoding?.CodePage
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ internal sealed class UpdateBufferRequest
[JsonPropertyName("hostDocumentVersion")]
public int? HostDocumentVersion { get; set; }

[JsonPropertyName("previousHostDocumentVersion")]
public int? PreviousHostDocumentVersion { get; set; }

[JsonPropertyName("projectKeyId")]
public string? ProjectKeyId { get; set; }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Razor.PooledObjects;
using Microsoft.CodeAnalysis.Razor.Logging;
using Microsoft.CodeAnalysis.Razor.Protocol;
using Microsoft.CodeAnalysis.Razor.Protocol.Completion;
using Microsoft.CodeAnalysis.Razor.Workspaces.Telemetry;
Expand Down Expand Up @@ -109,7 +110,11 @@ internal partial class RazorCustomMessageTarget
await _joinableTaskFactory.SwitchToMainThreadAsync(cancellationToken);

var provisionalChange = new VisualStudioTextChange(provisionalTextEdit, virtualDocumentSnapshot.Snapshot);
UpdateVirtualDocument(provisionalChange, request.ProjectedKind, request.Identifier.Version, hostDocumentUri, virtualDocumentSnapshot.Uri);
// We update to a negative version number so that if a request comes in for v6, it won't see our modified document. We revert the version back
// later, don't worry.
UpdateVirtualDocument(provisionalChange, request.ProjectedKind, -1 * request.Identifier.Version, hostDocumentUri, virtualDocumentSnapshot.Uri);
Copy link
Contributor

Choose a reason for hiding this comment

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

maybe a pathological case, but for small versions is there any concern? E.g we're setting this to -1 for v1. Could updates cause some sort of collision there? Should this just be int.MinValue + request.Identifier.Version?

Copy link
Member Author

Choose a reason for hiding this comment

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

So it needs to be less than any other version, so that when something comes in it doesn't think "oh, there is a newer version, I'll cancel." It could be MinValue, but I thought it actually looks nice in the logs that you can see which version caused the provisional update.

The only possible conflict would be two provisional completions at the same time but were protected by the UI thread from that.

Oh and not to toot my own horn but this situation is impossible to hit in cohosting 😝


_logger.LogDebug($"Updated for provisional completion to version -{request.Identifier.Version} of {virtualDocumentSnapshot!.Uri}.");

// We want the delegation to continue on the captured context because we're currently on the `main` thread and we need to get back to the
// main thread in order to update the virtual buffer with the reverted text edit.
Expand Down Expand Up @@ -163,6 +168,8 @@ internal partial class RazorCustomMessageTarget
{
if (provisionalTextEdit is not null)
{
_logger.LogDebug($"Reverting the update for provisional completion back to {request.Identifier.Version} of {virtualDocumentSnapshot!.Uri}.");

var revertedProvisionalTextEdit = BuildRevertedEdit(provisionalTextEdit);
var revertedProvisionalChange = new VisualStudioTextChange(revertedProvisionalTextEdit, virtualDocumentSnapshot.Snapshot);
UpdateVirtualDocument(revertedProvisionalChange, request.ProjectedKind, request.Identifier.Version, hostDocumentUri, virtualDocumentSnapshot.Uri);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using Microsoft.AspNetCore.Razor.ProjectSystem;
using Microsoft.CodeAnalysis.Razor.Logging;
using Microsoft.CodeAnalysis.Razor.Protocol;
using Microsoft.VisualStudio.LanguageServer.Protocol;
using StreamJsonRpc;

namespace Microsoft.VisualStudio.Razor.LanguageClient.Endpoints;
Expand All @@ -24,20 +25,33 @@ public async Task UpdateCSharpBufferAsync(UpdateBufferRequest request, Cancellat
throw new ArgumentNullException(nameof(request));
}

await _joinableTaskFactory.SwitchToMainThreadAsync(cancellationToken);

await UpdateCSharpBufferCoreAsync(request, cancellationToken);
// We're going to try updating the C# buffer, and it has to happen on the UI thread. That is a single shared resource
// that can only be updated on a specific thread, and so we can easily hit contention. In particular with provisional
// completion we can end up with a state where we are waiting for completion to finish before we can update the document
// but other features are waiting on us to update the document with some changes. Normally this is fine, and we handle it
// with our sync system and simply cancel the task and let the next one in. When updating buffers from the server though
// we can't do that, as the server assumes we can always apply the update. Our only option here is to keep trying until
// we get a successful update.
// It's worth noting we only try again specifically for provisional completion, so we don't get random deadocks.
var tryAgain = true;
while (tryAgain)
{
_logger.LogDebug($"Trying a call to UpdateCSharpBufferCoreAsync for v{request.HostDocumentVersion}");
tryAgain = await UpdateCSharpBufferCoreAsync(request, cancellationToken).ConfigureAwait(false);
}
}

// Internal for testing
internal async Task UpdateCSharpBufferCoreAsync(UpdateBufferRequest request, CancellationToken cancellationToken)
private async Task<bool> UpdateCSharpBufferCoreAsync(UpdateBufferRequest request, CancellationToken cancellationToken)
{
if (request is null || request.HostDocumentFilePath is null || request.HostDocumentVersion is null)
if (request.HostDocumentFilePath is null || request.HostDocumentVersion is null)
{
return;
return false;
}

var hostDocumentUri = new Uri(request.HostDocumentFilePath);
await _joinableTaskFactory.SwitchToMainThreadAsync(cancellationToken);

var identifier = CreateTextDocumentIdentifier(request);
var hostDocumentUri = identifier.Uri;

_logger.LogDebug($"UpdateCSharpBuffer for {request.HostDocumentVersion} of {hostDocumentUri} in {request.ProjectKeyId}");

Expand Down Expand Up @@ -68,12 +82,27 @@ request.ProjectKeyId is not null &&
// Sadly there isn't anything we can do here to, we're just in a state where the server and client are out of
// sync with their understanding of the document contents, and since changes come in as a list of changes,
// the user experience is broken. All we can do is hope the user closes and re-opens the document.
Debug.Fail($"Server wants to update {hostDocumentUri} in {request.ProjectKeyId} but we don't know about the document being in any projects");
_logger.LogError($"Server wants to update {hostDocumentUri} in {request.ProjectKeyId} by we only know about that document in misc files. Server and client are now out of sync.");
return;
Debug.Fail($"Server wants to update {hostDocumentUri} in {request.ProjectKeyId} but we don't know about the document being in any projects");
return false;
}
}

// First we need to make sure we're synced to the previous version, or the changes won't apply properly. This should no-op in most cases, as this
// is (almost) the only thing that actually moves documents forward, we're really just validating we're in a good state.
// We're specifically checking here for provisional completion in flight, which is a case where we update the C# document
// in a way that doesn't represent the actual state, so we can't let server updates through while in this state (represented
// by a negative version number). Due to provisional completion being on the UI thread, we have to return from this method
// and try again so we get to the back of the UI thread queue.
if (request.PreviousHostDocumentVersion is { } previousVersion &&
await TrySynchronizeVirtualDocumentAsync<CSharpVirtualDocumentSnapshot>(previousVersion, identifier, cancellationToken, rejectOnNewerParallelRequest: false) is { } synchronizedResult &&
!synchronizedResult.Synchronized &&
synchronizedResult.VirtualSnapshot?.HostDocumentSyncVersion < 0)
{
_logger.LogError($"Request to update C# buffer from {previousVersion} to {request.HostDocumentVersion} failed because the server Roslyn and Razor are out of sync. Server version is {synchronizedResult.VirtualSnapshot?.HostDocumentSyncVersion}. Will try again as provisional completion is in flight which is an expected cause of de-sync, which we recover from.");
return true;
}

foreach (var virtualDocument in virtualDocuments)
{
if (virtualDocument.ProjectKey.Equals(new ProjectKey(request.ProjectKeyId)))
Expand All @@ -89,7 +118,7 @@ request.ProjectKeyId is not null &&

_logger.LogDebug($"UpdateCSharpBuffer finished updating doc for {request.HostDocumentVersion} of {virtualDocument.Uri}. New lines: {GetLineCountOfVirtualDocument(hostDocumentUri, virtualDocument)}");

return;
return false;
}
}

Expand All @@ -108,7 +137,7 @@ request.ProjectKeyId is not null &&

// Don't know about document, no-op. This can happen if the language server found a project.razor.bin from an old build
// and is sending us updates.
return;
return false;
}

_logger.LogDebug($"UpdateCSharpBuffer fallback for {request.HostDocumentVersion} of {hostDocumentUri}");
Expand All @@ -118,6 +147,26 @@ request.ProjectKeyId is not null &&
request.Changes.Select(change => change.ToVisualStudioTextChange()).ToArray(),
request.HostDocumentVersion.Value,
state: request.PreviousWasEmpty);

return false;
}

private static TextDocumentIdentifier CreateTextDocumentIdentifier(UpdateBufferRequest request)
{
var hostDocumentUri = new Uri(request.HostDocumentFilePath);
if (request.ProjectKeyId is { } id)
{
return new VSTextDocumentIdentifier
{
Uri = hostDocumentUri,
ProjectContext = new VSProjectContext
{
Id = id,
}
};
}

return new TextDocumentIdentifier { Uri = hostDocumentUri };
}

private int GetLineCountOfVirtualDocument(Uri hostDocumentUri, CSharpVirtualDocumentSnapshot virtualDocument)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ public RazorCustomMessageTargetTest(ITestOutputHelper testOutput)
_editorSettingsManager = new ClientSettingsManager(Array.Empty<IClientSettingsChangedTrigger>());
}

[Fact]
[UIFact]
public async Task UpdateCSharpBuffer_CannotLookupDocument_NoopsGracefully()
{
// Arrange
Expand Down Expand Up @@ -78,10 +78,10 @@ public async Task UpdateCSharpBuffer_CannotLookupDocument_NoopsGracefully()
};

// Act & Assert
await target.UpdateCSharpBufferCoreAsync(request, DisposalToken);
await target.UpdateCSharpBufferAsync(request, DisposalToken);
}

[Fact]
[UIFact]
public async Task UpdateCSharpBuffer_UpdatesDocument()
{
// Arrange
Expand Down Expand Up @@ -124,13 +124,13 @@ public async Task UpdateCSharpBuffer_UpdatesDocument()
};

// Act
await target.UpdateCSharpBufferCoreAsync(request, DisposalToken);
await target.UpdateCSharpBufferAsync(request, DisposalToken);

// Assert
documentManager.VerifyAll();
}

[Fact]
[UIFact]
public async Task UpdateCSharpBuffer_UpdatesCorrectDocument()
{
// Arrange
Expand All @@ -142,8 +142,8 @@ public async Task UpdateCSharpBuffer_UpdatesCorrectDocument()
var document = Mock.Of<LSPDocumentSnapshot>(d => d.VirtualDocuments == documents, MockBehavior.Strict);
var documentManager = new Mock<TrackingLSPDocumentManager>(MockBehavior.Strict);
documentManager
.Setup(manager => manager.TryGetDocument(It.IsAny<Uri>(), out document))
.Returns(true);
.Setup(manager => manager.TryGetDocument(It.IsAny<Uri>(), out document))
.Returns(true);
documentManager
.Setup(manager => manager.UpdateVirtualDocument<CSharpVirtualDocument>(
It.IsAny<Uri>(),
Expand Down Expand Up @@ -181,7 +181,7 @@ public async Task UpdateCSharpBuffer_UpdatesCorrectDocument()
};

// Act
await target.UpdateCSharpBufferCoreAsync(request, DisposalToken);
await target.UpdateCSharpBufferAsync(request, DisposalToken);

// Assert
documentManager.VerifyAll();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,9 @@ private static string CreateTemporaryPath()

public override async Task DisposeAsync()
{
// TODO: Would be good to have this as a last ditch check, but need to improve the detection and reporting here to be more robust
//await TestServices.Editor.ValidateNoDiscoColorsAsync(HangMitigatingCancellationToken);

_testLogger!.LogInformation($"#### Razor integration test dispose.");

TestServices.Output.ClearIntegrationTestLogger();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -294,33 +294,6 @@ protected override void OnAfterRender(bool firstRender)
commitChar: '\t');
}

private async Task VerifyTypeAndCommitCompletionAsync(string input, string output, string search, string[] stringsToType, char? commitChar = null, string? expectedSelectedItemLabel = null)
Copy link
Member Author

Choose a reason for hiding this comment

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

This method just moved down to below all of the actual tests.

{
await TestServices.SolutionExplorer.AddFileAsync(
RazorProjectConstants.BlazorProjectName,
"Test.razor",
input,
open: true,
ControlledHangMitigatingCancellationToken);

await TestServices.Editor.WaitForComponentClassificationAsync(ControlledHangMitigatingCancellationToken);

await TestServices.Editor.PlaceCaretAsync(search, charsOffset: 1, ControlledHangMitigatingCancellationToken);
foreach (var stringToType in stringsToType)
{
TestServices.Input.Send(stringToType);
}

if (expectedSelectedItemLabel is not null)
{
await CommitCompletionAndVerifyAsync(output, expectedSelectedItemLabel, commitChar);
}
else
{
await CommitCompletionAndVerifyAsync(output, commitChar);
}
}

[IdeFact]
public async Task SnippetCompletion_DoesntCommitOnSpace()
{
Expand Down Expand Up @@ -477,6 +450,78 @@ public enum MyEnum
""");
}

[IdeFact, WorkItem("https://github.com/dotnet/razor/issues/11385")]
public async Task ProvisionalCompletion_DoesntBreakSemanticTokens()
{
await TestServices.SolutionExplorer.AddFileAsync(
RazorProjectConstants.BlazorProjectName,
"Test.razor",
"""
@page "/counter"

<PageTitle>Counter</PageTitle>

<h1>Counter</h1>

<p role="status">Current count: @currentCount</p>

@DateTime

<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>

@code {
private int currentCount = 0;

public void IncrementCount()
{
currentCount++;
}
}
""",
open: true,
ControlledHangMitigatingCancellationToken);

await TestServices.Editor.WaitForComponentClassificationAsync(ControlledHangMitigatingCancellationToken);

await TestServices.Editor.PlaceCaretAsync("@DateTime", charsOffset: 1, ControlledHangMitigatingCancellationToken);

await Task.Delay(500, HangMitigatingCancellationToken);

TestServices.Input.Send(".");
TestServices.Input.Send("n");
Comment on lines +490 to +491
Copy link
Member Author

Choose a reason for hiding this comment

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

Full disclosure: This test on my machine doesn't type fast enough to repro the issue reliably, but it did do it sometimes.

Copy link
Contributor

Choose a reason for hiding this comment

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

Probably worth adding a comment on the test that if it's flaky the cause might be a regression in what you fixed. That way future Dave™️ remembers

Copy link
Member Author

Choose a reason for hiding this comment

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

Not sure I follow. The disco colors detection is now in every test essentially, so this test really just tests provisional completion. If any test causes disco colors, that test will fail so the scenario that caused it will be in the name of the test.

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'm re-reading my comment, and I think I should expand: This test had flakiness in terms of false-positives (ie, it would sometimes pass) before my code change. After my code change I don't expect it to fail or be flaky.


await Task.Delay(500, HangMitigatingCancellationToken);

await TestServices.Editor.ValidateNoDiscoColorsAsync(HangMitigatingCancellationToken);
}

private async Task VerifyTypeAndCommitCompletionAsync(string input, string output, string search, string[] stringsToType, char? commitChar = null, string? expectedSelectedItemLabel = null)
{
await TestServices.SolutionExplorer.AddFileAsync(
RazorProjectConstants.BlazorProjectName,
"Test.razor",
input,
open: true,
ControlledHangMitigatingCancellationToken);

await TestServices.Editor.WaitForComponentClassificationAsync(ControlledHangMitigatingCancellationToken);

await TestServices.Editor.PlaceCaretAsync(search, charsOffset: 1, ControlledHangMitigatingCancellationToken);
foreach (var stringToType in stringsToType)
{
TestServices.Input.Send(stringToType);
}

if (expectedSelectedItemLabel is not null)
{
await CommitCompletionAndVerifyAsync(output, expectedSelectedItemLabel, commitChar);
}
else
{
await CommitCompletionAndVerifyAsync(output, commitChar);
}
}

private async Task CommitCompletionAndVerifyAsync(string expected, char? commitChar = null)
{
var session = await TestServices.Editor.WaitForCompletionSessionAsync(HangMitigatingCancellationToken);
Expand Down
Loading