From 23ef2a189a6865c648f71d7541e16fffa8db165a Mon Sep 17 00:00:00 2001 From: Andrew Hall Date: Fri, 16 Aug 2024 14:43:21 -0700 Subject: [PATCH 01/15] Move serialization logic for message pack to be generic --- .../ProjectSystem/RazorProjectInfo.cs | 30 --------------- .../RazorMessagePackSerializer.cs | 38 +++++++++++++++++++ .../StreamExtensionTests.NetCore.cs | 2 +- 3 files changed, 39 insertions(+), 31 deletions(-) create mode 100644 src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/Serialization/RazorMessagePackSerializer.cs diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/ProjectSystem/RazorProjectInfo.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/ProjectSystem/RazorProjectInfo.cs index 86158c6ceb9..7a05e789bce 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/ProjectSystem/RazorProjectInfo.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/ProjectSystem/RazorProjectInfo.cs @@ -2,17 +2,10 @@ // Licensed under the MIT license. See License.txt in the project root for license information. using System; -using System.Buffers; using System.Collections.Immutable; -using System.IO; using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using MessagePack; -using MessagePack.Resolvers; using Microsoft.AspNetCore.Razor.Language; using Microsoft.AspNetCore.Razor.Serialization; -using Microsoft.AspNetCore.Razor.Serialization.MessagePack.Resolvers; using Microsoft.CodeAnalysis; using Microsoft.Extensions.Internal; @@ -20,11 +13,6 @@ namespace Microsoft.AspNetCore.Razor.ProjectSystem; internal sealed record class RazorProjectInfo { - private static readonly MessagePackSerializerOptions s_options = MessagePackSerializerOptions.Standard - .WithResolver(CompositeResolver.Create( - RazorProjectInfoResolver.Instance, - StandardResolver.Instance)); - public ProjectKey ProjectKey { get; init; } public string FilePath { get; init; } public RazorConfiguration Configuration { get; init; } @@ -75,22 +63,4 @@ public override int GetHashCode() return hash.CombinedHash; } - - public byte[] Serialize() - => MessagePackSerializer.Serialize(this, s_options); - - public void SerializeTo(IBufferWriter bufferWriter) - => MessagePackSerializer.Serialize(bufferWriter, this, s_options); - - public void SerializeTo(Stream stream) - => MessagePackSerializer.Serialize(stream, this, s_options); - - public static RazorProjectInfo? DeserializeFrom(ReadOnlyMemory buffer) - => MessagePackSerializer.Deserialize(buffer, s_options); - - public static RazorProjectInfo? DeserializeFrom(Stream stream) - => MessagePackSerializer.Deserialize(stream, s_options); - - public static ValueTask DeserializeFromAsync(Stream stream, CancellationToken cancellationToken) - => MessagePackSerializer.DeserializeAsync(stream, s_options, cancellationToken); } diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/Serialization/RazorMessagePackSerializer.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/Serialization/RazorMessagePackSerializer.cs new file mode 100644 index 00000000000..0d7cf7291dd --- /dev/null +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/Serialization/RazorMessagePackSerializer.cs @@ -0,0 +1,38 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT license. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; +using MessagePack.Resolvers; +using MessagePack; +using Microsoft.AspNetCore.Razor.Serialization.MessagePack.Resolvers; +using System.Buffers; +using System.IO; +using System.Threading; + +namespace Microsoft.AspNetCore.Razor.Serialization; +internal static class RazorMessagePackSerializer +{ + private static readonly MessagePackSerializerOptions s_options = MessagePackSerializerOptions.Standard + .WithResolver(CompositeResolver.Create( + RazorProjectInfoResolver.Instance, + StandardResolver.Instance)); + + public static byte[] Serialize(T instance) + => MessagePackSerializer.Serialize(instance, s_options); + + public static void SerializeTo(T instance, IBufferWriter bufferWriter) + => MessagePackSerializer.Serialize(bufferWriter, instance, s_options); + + public static void SerializeTo(T instance, Stream stream) + => MessagePackSerializer.Serialize(stream, instance, s_options); + + public static T? DeserializeFrom(ReadOnlyMemory buffer) + => MessagePackSerializer.Deserialize(buffer, s_options); + + public static T? DeserializeFrom(Stream stream) + => MessagePackSerializer.Deserialize(stream, s_options); + + public static ValueTask DeserializeFromAsync(Stream stream, CancellationToken cancellationToken) + => MessagePackSerializer.DeserializeAsync(stream, s_options, cancellationToken); +} diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.ProjectEngineHost.Test/StreamExtensionTests.NetCore.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.ProjectEngineHost.Test/StreamExtensionTests.NetCore.cs index 8895137d798..bbaecd1749f 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.ProjectEngineHost.Test/StreamExtensionTests.NetCore.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.ProjectEngineHost.Test/StreamExtensionTests.NetCore.cs @@ -88,7 +88,7 @@ public async Task SerializeProjectInfo() projectWorkspaceState, [new DocumentSnapshotHandle(@"C:\test\document.razor", @"document.razor", FileKinds.Component)]); - var bytesToSerialize = projectInfo.Serialize(); + var bytesToSerialize = RazorMessagePackSerializer.Serialize(projectInfo); await stream.WriteProjectInfoAsync(projectInfo, default); From 9fa70b28a0470bcf9794b12b315101fdba7f1cb2 Mon Sep 17 00:00:00 2001 From: Andrew Hall Date: Fri, 16 Aug 2024 14:44:15 -0700 Subject: [PATCH 02/15] Move project info factory to ProjectEngineHost, change name --- .../RazorProjectInfoHelpers.cs} | 116 +++++++++++------- .../RazorProjectInfoSerializerTest.cs | 10 +- 2 files changed, 76 insertions(+), 50 deletions(-) rename src/Razor/src/{Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorProjectInfoFactory.cs => Microsoft.AspNetCore.Razor.ProjectEngineHost/RazorProjectInfoHelpers.cs} (71%) diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorProjectInfoFactory.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/RazorProjectInfoHelpers.cs similarity index 71% rename from src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorProjectInfoFactory.cs rename to src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/RazorProjectInfoHelpers.cs index c99a25ec283..573bb837edd 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorProjectInfoFactory.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/RazorProjectInfoHelpers.cs @@ -1,9 +1,13 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT license. See License.txt in the project root for license information. +using System; using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; +using System.IO; using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; using Microsoft.AspNetCore.Razor.Language; using Microsoft.AspNetCore.Razor.PooledObjects; using Microsoft.AspNetCore.Razor.ProjectEngineHost; @@ -15,60 +19,69 @@ using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.Diagnostics; using Microsoft.CodeAnalysis.Razor; -using Microsoft.Extensions.Logging; -namespace Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace; +namespace Microsoft.AspNetCore.Razor; -internal static class RazorProjectInfoFactory +internal static class RazorProjectInfoHelpers { private static readonly StringComparison s_stringComparison; - static RazorProjectInfoFactory() + static RazorProjectInfoHelpers() { s_stringComparison = RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; } - public static async Task ConvertAsync(Project project, ILogger? logger, CancellationToken cancellationToken) + public static async Task ConvertAsync( + Project project, + string projectPath, + string intermediateOutputPath, + RazorConfiguration? razorConfiguration, + string? defaultNamespace, + ProjectWorkspaceState? projectWorkspaceState, + ImmutableArray documents, + CancellationToken cancellationToken) { - var projectPath = Path.GetDirectoryName(project.FilePath); - if (projectPath is null) + if (documents.IsDefault) { - logger?.LogInformation("projectPath is null, skip conversion for {projectId}", project.Id); - return null; + documents = GetDocuments(project, projectPath); } - var intermediateOutputPath = Path.GetDirectoryName(project.CompilationOutputInfo.AssemblyPath); - if (intermediateOutputPath is null) + // Not a razor project + if (documents.Length == 0) { - logger?.LogInformation("intermediatePath is null, skip conversion for {projectId}", project.Id); return null; } - // First, lets get the documents, because if there aren't any, we can skip out early - var documents = GetDocuments(project, projectPath); + if (razorConfiguration is null) + { + var options = project.AnalyzerOptions.AnalyzerConfigOptionsProvider; + razorConfiguration = ComputeRazorConfigurationOptions(options, out defaultNamespace); + } - // Not a razor project - if (documents.Length == 0) + if (projectWorkspaceState is null) { - if (project.DocumentIds.Count == 0) + projectWorkspaceState = await GetWorkspaceStateAsync(project, razorConfiguration, defaultNamespace, projectPath, cancellationToken).ConfigureAwait(false); + if (projectWorkspaceState is null) { - logger?.LogInformation("No razor documents for {projectId}", project.Id); + return null; } - else - { - logger?.LogTrace("No documents in {projectId}", project.Id); - } - - return null; } - var csharpLanguageVersion = (project.ParseOptions as CSharpParseOptions)?.LanguageVersion ?? LanguageVersion.Default; - - var options = project.AnalyzerOptions.AnalyzerConfigOptionsProvider; - var configuration = ComputeRazorConfigurationOptions(options, logger, out var defaultNamespace); + return new RazorProjectInfo( + projectKey: new ProjectKey(intermediateOutputPath), + filePath: project.FilePath!, + configuration: razorConfiguration, + rootNamespace: defaultNamespace, + displayName: project.Name, + projectWorkspaceState: projectWorkspaceState, + documents: documents); + } + public static async Task GetWorkspaceStateAsync(Project project, RazorConfiguration configuration, string? defaultNamespace, string projectPath, CancellationToken cancellationToken) + { + var csharpLanguageVersion = (project.ParseOptions as CSharpParseOptions)?.LanguageVersion ?? LanguageVersion.Default; var fileSystem = RazorProjectFileSystem.Create(projectPath); var defaultConfigure = (RazorProjectEngineBuilder builder) => @@ -92,19 +105,35 @@ static RazorProjectInfoFactory() var resolver = new CompilationTagHelperResolver(NoOpTelemetryReporter.Instance); var tagHelpers = await resolver.GetTagHelpersAsync(project, engine, cancellationToken).ConfigureAwait(false); - var projectWorkspaceState = ProjectWorkspaceState.Create(tagHelpers, csharpLanguageVersion); + return ProjectWorkspaceState.Create(tagHelpers, csharpLanguageVersion); + } - return new RazorProjectInfo( - projectKey: new ProjectKey(intermediateOutputPath), - filePath: project.FilePath!, - configuration: configuration, - rootNamespace: defaultNamespace, - displayName: project.Name, - projectWorkspaceState: projectWorkspaceState, - documents: documents); + public static RazorProjectEngine? GetProjectEngine(Project project, string projectPath) + { + var options = project.AnalyzerOptions.AnalyzerConfigOptionsProvider; + var configuration = ComputeRazorConfigurationOptions(options, out var defaultNamespace); + var csharpLanguageVersion = (project.ParseOptions as CSharpParseOptions)?.LanguageVersion ?? LanguageVersion.Default; + var fileSystem = RazorProjectFileSystem.Create(projectPath); + var defaultConfigure = (RazorProjectEngineBuilder builder) => + { + if (defaultNamespace is not null) + { + builder.SetRootNamespace(defaultNamespace); + } + + builder.SetCSharpLanguageVersion(csharpLanguageVersion); + builder.SetSupportLocalizedComponentNames(); // ProjectState in MS.CA.Razor.Workspaces does this, so I'm doing it too! + }; + + var engineFactory = ProjectEngineFactories.DefaultProvider.GetFactory(configuration); + + return engineFactory.Create( + configuration, + fileSystem, + configure: defaultConfigure); } - private static RazorConfiguration ComputeRazorConfigurationOptions(AnalyzerConfigOptionsProvider options, ILogger? logger, out string defaultNamespace) + public static RazorConfiguration ComputeRazorConfigurationOptions(AnalyzerConfigOptionsProvider options, out string defaultNamespace) { // See RazorSourceGenerator.RazorProviders.cs @@ -119,7 +148,6 @@ private static RazorConfiguration ComputeRazorConfigurationOptions(AnalyzerConfi if (!globalOptions.TryGetValue("build_property.RazorLangVersion", out var razorLanguageVersionString) || !RazorLanguageVersion.TryParse(razorLanguageVersionString, out var razorLanguageVersion)) { - logger?.LogTrace("Using default of latest language version"); razorLanguageVersion = RazorLanguageVersion.Latest; } @@ -130,7 +158,7 @@ private static RazorConfiguration ComputeRazorConfigurationOptions(AnalyzerConfi return razorConfiguration; } - internal static ImmutableArray GetDocuments(Project project, string projectPath) + public static ImmutableArray GetDocuments(Project project, string projectPath) { using var documents = new PooledArrayBuilder(); @@ -182,7 +210,7 @@ private static string GetTargetPath(string documentFilePath, string normalizedPr private static bool TryGetFileKind(string filePath, [NotNullWhen(true)] out string? fileKind) { - var extension = Path.GetExtension(filePath.AsSpan()); + var extension = Path.GetExtension(filePath); if (extension.Equals(".cshtml", s_stringComparison)) { @@ -213,11 +241,9 @@ private static bool TryGetRazorFileName(string? filePath, [NotNullWhen(true)] ou const string generatedRazorExtension = $".razor{suffix}"; const string generatedCshtmlExtension = $".cshtml{suffix}"; - var path = filePath.AsSpan(); - // Generated files have a path like: virtualcsharp-razor:///e:/Scratch/RazorInConsole/Goo.cshtml__virtual.cs - if (path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) && - (path.EndsWith(generatedRazorExtension, s_stringComparison) || path.EndsWith(generatedCshtmlExtension, s_stringComparison))) + if (filePath.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) && + (filePath.EndsWith(generatedRazorExtension, s_stringComparison) || filePath.EndsWith(generatedCshtmlExtension, s_stringComparison))) { // Go through the file path normalizer because it also does Uri decoding, and we're converting from a Uri to a path // but "new Uri(filePath).LocalPath" seems wasteful diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.Test/RazorProjectInfoSerializerTest.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.Test/RazorProjectInfoSerializerTest.cs index 465d490de1f..a56d2cf705a 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.Test/RazorProjectInfoSerializerTest.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.Test/RazorProjectInfoSerializerTest.cs @@ -21,7 +21,7 @@ public void GeneratedDocument() .WithFilePath("virtualcsharp-razor:///e:/Scratch/RazorInConsole/Goo.cshtml__virtual.cs") .Project; - var documents = RazorProjectInfoFactory.GetDocuments(project, "temp"); + var documents = RazorProjectInfoHelpers.GetDocuments(project, "temp"); Assert.Single(documents); } @@ -35,7 +35,7 @@ public void AdditionalDocument() project = workspace.CurrentSolution.GetProject(project.Id)!; - var documents = RazorProjectInfoFactory.GetDocuments(project, "temp"); + var documents = RazorProjectInfoHelpers.GetDocuments(project, "temp"); Assert.Single(documents); } @@ -53,7 +53,7 @@ public void AdditionalAndGeneratedDocument() .WithFilePath("virtualcsharp-razor:///e:/Scratch/RazorInConsole/Another.cshtml__virtual.cs") .Project; - var documents = RazorProjectInfoFactory.GetDocuments(project, "temp"); + var documents = RazorProjectInfoHelpers.GetDocuments(project, "temp"); Assert.Single(documents); } @@ -71,7 +71,7 @@ public void AdditionalNonRazorAndGeneratedDocument() .WithFilePath("virtualcsharp-razor:///e:/Scratch/RazorInConsole/Another.cshtml__virtual.cs") .Project; - var documents = RazorProjectInfoFactory.GetDocuments(project, "temp"); + var documents = RazorProjectInfoHelpers.GetDocuments(project, "temp"); Assert.Single(documents); } @@ -85,7 +85,7 @@ public void NormalDocument() .WithFilePath("e:/Scratch/RazorInConsole/Goo.cs") .Project; - var documents = RazorProjectInfoFactory.GetDocuments(project, "temp"); + var documents = RazorProjectInfoHelpers.GetDocuments(project, "temp"); Assert.Empty(documents); } From ba2c2571f9e8c125e16b9efa445d19926584d585 Mon Sep 17 00:00:00 2001 From: Andrew Hall Date: Fri, 16 Aug 2024 14:44:44 -0700 Subject: [PATCH 03/15] Use checksum to determine if information should be sent --- .../src/Language/RazorConfiguration.cs | 15 ++ ...RazorWorkspaceListenerBase.ProjectEntry.cs | 15 ++ .../RazorWorkspaceListenerBase.Work.cs | 13 ++ .../RazorWorkspaceListenerBase.cs | 69 ++++--- .../CachedTagHelperResolver.DeltaResult.cs | 47 +++++ .../CachedTagHelperResolver.cs | 172 ++++++++++++++++++ .../ProjectSystem/ProjectWorkspaceState.cs | 10 + .../ProjectSystem/RazorProjectInfo.cs | 24 +++ .../RazorProjectInfoHelpers.cs | 51 ++---- .../Serialization/DocumentSnapshotHandle.cs | 13 +- .../RazorMessagePackSerializer.cs | 1 + .../TagHelperResultCache.cs | 3 +- .../Utilities/StreamExtensions.NetCore.cs | 11 +- .../RemoteTagHelperDeltaProvider.cs | 2 +- .../RazorWorkspaceListenerTest.cs | 53 ++++++ 15 files changed, 440 insertions(+), 59 deletions(-) create mode 100644 src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListenerBase.ProjectEntry.cs create mode 100644 src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListenerBase.Work.cs create mode 100644 src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/CachedTagHelperResolver.DeltaResult.cs create mode 100644 src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/CachedTagHelperResolver.cs rename src/Razor/src/{Microsoft.CodeAnalysis.Razor.Workspaces/Remote => Microsoft.AspNetCore.Razor.ProjectEngineHost}/TagHelperResultCache.cs (96%) diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorConfiguration.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorConfiguration.cs index a1b1ba4b487..6172a83b514 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorConfiguration.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorConfiguration.cs @@ -1,8 +1,10 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System; using System.Collections.Immutable; using System.Linq; +using Microsoft.AspNetCore.Razor.Utilities; using Microsoft.Extensions.Internal; namespace Microsoft.AspNetCore.Razor.Language; @@ -39,4 +41,17 @@ public override int GetHashCode() hash.Add(LanguageServerFlags); return hash; } + + internal void CalculateChecksum(Checksum.Builder builder) + { + builder.AppendData(LanguageVersion.Major); + builder.AppendData(LanguageVersion.Minor); + builder.AppendData(ConfigurationName); + builder.AppendData(UseConsolidatedMvcViews); + + foreach (var extension in Extensions) + { + builder.AppendData(extension.ExtensionName); + } + } } diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListenerBase.ProjectEntry.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListenerBase.ProjectEntry.cs new file mode 100644 index 00000000000..53bac59b592 --- /dev/null +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListenerBase.ProjectEntry.cs @@ -0,0 +1,15 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT license. See License.txt in the project root for license information. + +using Microsoft.AspNetCore.Razor.Utilities; + +namespace Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace; + +public abstract partial class RazorWorkspaceListenerBase +{ + internal class ProjectEntry + { + public int? TagHelpersResultId { get; set; } + public Checksum? ProjectChecksum { get; set; } + } +} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListenerBase.Work.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListenerBase.Work.cs new file mode 100644 index 00000000000..bc9a0e215bd --- /dev/null +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListenerBase.Work.cs @@ -0,0 +1,13 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT license. See License.txt in the project root for license information. + +using Microsoft.CodeAnalysis; + +namespace Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace; + +public abstract partial class RazorWorkspaceListenerBase +{ + internal record Work(ProjectId ProjectId); + internal record UpdateWork(ProjectId ProjectId) : Work(ProjectId); + internal record RemovalWork(ProjectId ProjectId, string IntermediateOutputPath) : Work(ProjectId); +} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListenerBase.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListenerBase.cs index 7ab3ecf2a4b..48542f31739 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListenerBase.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListenerBase.cs @@ -3,19 +3,24 @@ using System.Collections.Immutable; using System.Diagnostics; +using Microsoft.AspNetCore.Razor.Serialization; +using Microsoft.AspNetCore.Razor.Telemetry; using Microsoft.AspNetCore.Razor.Utilities; using Microsoft.CodeAnalysis; using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace; -public abstract class RazorWorkspaceListenerBase : IDisposable +public abstract partial class RazorWorkspaceListenerBase : IDisposable { private static readonly TimeSpan s_debounceTime = TimeSpan.FromMilliseconds(500); private readonly CancellationTokenSource _disposeTokenSource = new(); private readonly ILogger _logger; private readonly AsyncBatchingWorkQueue _workQueue; + private readonly CachedTagHelperResolver _cachedTagHelperResolver = new(NoOpTelemetryReporter.Instance); + private readonly Dictionary> _documentSnapshots = new(); + private readonly Dictionary _projectEntryMap = new(); // Use an immutable dictionary for ImmutableInterlocked operations. The value isn't checked, just // the existance of the key so work is only done for projects with dynamic files. @@ -25,10 +30,6 @@ public abstract class RazorWorkspaceListenerBase : IDisposable private Workspace? _workspace; private bool _disposed; - internal record Work(ProjectId ProjectId); - internal record UpdateWork(ProjectId ProjectId) : Work(ProjectId); - internal record RemovalWork(ProjectId ProjectId, string IntermediateOutputPath) : Work(ProjectId); - private protected RazorWorkspaceListenerBase(ILogger logger) { _logger = logger; @@ -195,7 +196,7 @@ void RemoveProject(Project project) { // Remove project is called from Workspace.Changed, while other notifications of _projectsWithDynamicFile // are handled with NotifyDynamicFile. Use ImmutableInterlocked here to be sure the updates happen - // in a thread safe manner since those are not assumed to be the same thread. + // in a thread safe manner since those are not assumed to be the same thread. if (ImmutableInterlocked.TryRemove(ref _projectsWithDynamicFile, project.Id, out var _)) { var intermediateOutputPath = Path.GetDirectoryName(project.CompilationOutputInfo.AssemblyPath); @@ -227,7 +228,7 @@ private protected async virtual ValueTask ProcessWorkAsync(ImmutableArray cancellationToken.ThrowIfCancellationRequested(); - // Early bail check for if we are disposed or somewhere in the middle of disposal + // Early bail check for if we are disposed or somewhere in the middle of disposal if (_disposed || stream is null || solution is null) { _logger.LogTrace("Skipping work due to disposal"); @@ -235,10 +236,10 @@ private protected async virtual ValueTask ProcessWorkAsync(ImmutableArray } await CheckConnectionAsync(stream, cancellationToken).ConfigureAwait(false); - await ProcessWorkCoreAsync(work, stream, solution, _logger, cancellationToken).ConfigureAwait(false); + await ProcessWorkCoreAsync(work, stream, solution, cancellationToken).ConfigureAwait(false); } - private static async Task ProcessWorkCoreAsync(ImmutableArray work, Stream stream, Solution solution, ILogger logger, CancellationToken cancellationToken) + private async Task ProcessWorkCoreAsync(ImmutableArray work, Stream stream, Solution solution, CancellationToken cancellationToken) { foreach (var unit in work) { @@ -248,21 +249,21 @@ private static async Task ProcessWorkCoreAsync(ImmutableArray work, Stream if (unit is RemovalWork removalWork) { - await ReportRemovalAsync(stream, removalWork, logger, cancellationToken).ConfigureAwait(false); + await ReportRemovalAsync(stream, removalWork, _logger, cancellationToken).ConfigureAwait(false); } var project = solution.GetProject(unit.ProjectId); if (project is null) { - logger.LogTrace("Project {projectId} is not in workspace", unit.ProjectId); + _logger.LogTrace("Project {projectId} is not in workspace", unit.ProjectId); continue; } - await ReportUpdateProjectAsync(stream, project, logger, cancellationToken).ConfigureAwait(false); + await ReportUpdateProjectAsync(stream, project, cancellationToken).ConfigureAwait(false); } catch (Exception ex) when (ex is not OperationCanceledException) { - logger.LogError(ex, "Encountered exception while processing unit: {message}", ex.Message); + _logger.LogError(ex, "Encountered exception while processing unit: {message}", ex.Message); } } @@ -272,22 +273,48 @@ private static async Task ProcessWorkCoreAsync(ImmutableArray work, Stream } catch (Exception ex) { - logger.LogError(ex, "Encountered error flusingh stream"); + _logger.LogError(ex, "Encountered error flushing stream"); } } - private static async Task ReportUpdateProjectAsync(Stream stream, Project project, ILogger logger, CancellationToken cancellationToken) + private async Task ReportUpdateProjectAsync(Stream stream, Project project, CancellationToken cancellationToken) { - logger.LogTrace("Serializing information for {projectId}", project.Id); - var projectInfo = await RazorProjectInfoFactory.ConvertAsync(project, logger, cancellationToken).ConfigureAwait(false); - if (projectInfo is null) + _logger.LogTrace("Serializing information for {projectId}", project.Id); + var projectPath = Path.GetDirectoryName(project.FilePath); + if (projectPath is null) { - logger.LogTrace("Skipped writing data for {projectId}", project.Id); + _logger.LogInformation("projectPath is null, skip update for {projectId}", project.Id); return; } - stream.WriteProjectInfoAction(ProjectInfoAction.Update); - await stream.WriteProjectInfoAsync(projectInfo, cancellationToken).ConfigureAwait(false); + var intermediateOutputPath = Path.GetDirectoryName(project.CompilationOutputInfo.AssemblyPath); + if (intermediateOutputPath is null) + { + _logger.LogInformation("intermediateOutputPath is null, skip update for {projectId}", project.Id); + return; + } + + var entry = _projectEntryMap.GetOrAdd(project.Id, static _ => new ProjectEntry()); + + var delta = await _cachedTagHelperResolver.GetDeltaAsync(project, entry.TagHelpersResultId, cancellationToken).ConfigureAwait(false); + entry.TagHelpersResultId = delta.ResultId; + + var tagHelpers = _cachedTagHelperResolver.GetValues(project.Id, delta.ResultId); + var projectInfo = RazorProjectInfoHelpers.TryConvert(project, projectPath, intermediateOutputPath, tagHelpers); + if (projectInfo is not null) + { + var checkSum = projectInfo.Checksum; + if (entry.ProjectChecksum == checkSum) + { + _logger.LogInformation("Checksum for {projectId} did not change. Skipped sending update", project.Id); + return; + } + + entry.ProjectChecksum = checkSum; + + stream.WriteProjectInfoAction(ProjectInfoAction.Update); + await stream.WriteProjectInfoAsync(projectInfo, cancellationToken).ConfigureAwait(false); + } } private static Task ReportRemovalAsync(Stream stream, RemovalWork unit, ILogger logger, CancellationToken cancellationToken) diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/CachedTagHelperResolver.DeltaResult.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/CachedTagHelperResolver.DeltaResult.cs new file mode 100644 index 00000000000..f011a847ba4 --- /dev/null +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/CachedTagHelperResolver.DeltaResult.cs @@ -0,0 +1,47 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT license. See License.txt in the project root for license information. + +using System.Collections.Immutable; +using Microsoft.AspNetCore.Razor.PooledObjects; +using Microsoft.AspNetCore.Razor.Utilities; + +#if DEBUG +using System.Diagnostics; +#endif + +namespace Microsoft.AspNetCore.Razor; + +internal partial class CachedTagHelperResolver +{ + public sealed record class DeltaResult( + bool IsDelta, + int ResultId, + ImmutableArray Added, + ImmutableArray Removed) + { + public ImmutableArray Apply(ImmutableArray baseChecksums) + { + if (Added.Length == 0 && Removed.Length == 0) + { + return baseChecksums; + } + + using var result = new PooledArrayBuilder(capacity: baseChecksums.Length + Added.Length - Removed.Length); + + result.AddRange(Added); + result.AddRange(Delta.Compute(Removed, baseChecksums)); + +#if DEBUG + // Ensure that there are no duplicate tag helpers in the result. + using var set = new PooledHashSet(capacity: result.Count); + + foreach (var item in result) + { + Debug.Assert(set.Add(item), $"{nameof(DeltaResult)}.{nameof(Apply)} should not contain any duplicates!"); + } +#endif + + return result.DrainToImmutable(); + } + } +} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/CachedTagHelperResolver.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/CachedTagHelperResolver.cs new file mode 100644 index 00000000000..476c0c598af --- /dev/null +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/CachedTagHelperResolver.cs @@ -0,0 +1,172 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT license. See License.txt in the project root for license information. + +using System.Collections.Immutable; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.PooledObjects; +using Microsoft.AspNetCore.Razor.Telemetry; +using Microsoft.AspNetCore.Razor.Utilities; +using Microsoft.CodeAnalysis; + +namespace Microsoft.AspNetCore.Razor; + +internal partial class CachedTagHelperResolver(ITelemetryReporter telemetryReporter) +{ + private readonly CompilationTagHelperResolver _innerResolver = new(telemetryReporter); + private readonly object _gate = new(); + private int _currentResultId; + + private record Entry(int ResultId, ImmutableArray Checksums); + private readonly MemoryCache _projectResultCache = new(); + + public ImmutableArray GetValues(ProjectId projectKey, int resultId) + { + if (!TryGetCachedChecksums(projectKey, resultId, out var cachedChecksums)) + { + return []; + } + + using var _ = ArrayBuilderPool.GetPooledObject(out var builder); + foreach (var checksum in cachedChecksums) + { + var value = TryGet(checksum); + if (value is null) + { + continue; + } + + builder.Add(value); + } + + return builder.ToImmutableArray(); + } + + public async ValueTask GetDeltaAsync(Project project, int? lastResultIdNullable, CancellationToken cancellationToken) + { + var lastResultId = lastResultIdNullable ?? -1; + + var currentChecksums = await GetCurrentChecksumsAsync(project, cancellationToken).ConfigureAwait(false); + var cacheHit = TryGetCachedChecksums(project.Id, lastResultId, out var cachedChecksums); + + if (!cacheHit) + { + cachedChecksums = []; + } + + ImmutableArray added; + ImmutableArray removed; + + if (cachedChecksums.Length < currentChecksums.Length) + { + added = Delta.Compute(cachedChecksums, currentChecksums); + + // No need to call TagHelperDelta.Compute again if we know there aren't any removed + var anyRemoved = currentChecksums.Length - cachedChecksums.Length != added.Length; + removed = anyRemoved ? Delta.Compute(currentChecksums, cachedChecksums) : ImmutableArray.Empty; + } + else + { + removed = Delta.Compute(currentChecksums, cachedChecksums); + + // No need to call TagHelperDelta.Compute again if we know there aren't any added + var anyAdded = cachedChecksums.Length - currentChecksums.Length != removed.Length; + added = anyAdded ? Delta.Compute(cachedChecksums, currentChecksums) : ImmutableArray.Empty; + } + + lock (_gate) + { + var newResultId = _currentResultId; + if (added.Length > 0 || removed.Length > 0) + { + // The result actually changed, lets generate & cache a new result + newResultId = ++_currentResultId; + SetCachedChecksums(project.Id, newResultId, currentChecksums); + } + else if (cacheHit) + { + // Re-use existing result ID if we've hit he cache so next time we get asked we hit again. + newResultId = lastResultId; + } + + return new DeltaResult(cacheHit, newResultId, added, removed); + } + } + + public bool TryGetCachedChecksums(ProjectId projectKey, int resultId, out ImmutableArray cachedChecksums) + { + if (!_projectResultCache.TryGetValue(projectKey, out var cachedResult)) + { + cachedChecksums = default; + return false; + } + else if (cachedResult.ResultId != resultId) + { + // We don't know about the result that's being requested. Fallback to uncached behavior. + cachedChecksums = default; + return false; + } + + cachedChecksums = cachedResult.Checksums; + return true; + } + + public bool TryGetId(ProjectId projectKey, out int resultId) + { + if (!_projectResultCache.TryGetValue(projectKey, out var cachedResult)) + { + resultId = -1; + return false; + } + + resultId = cachedResult.ResultId; + return true; + } + + public void SetCachedChecksums(ProjectId projectKey, int resultId, ImmutableArray checksums) + { + var cacheEntry = new Entry(resultId, checksums); + _projectResultCache.Set(projectKey, cacheEntry); + } + + protected TagHelperDescriptor? TryGet(Checksum checksum) + { + TagHelperCache.Default.TryGet(checksum, out var tagHelper); + return tagHelper; + } + + protected async ValueTask> GetCurrentChecksumsAsync(Project project, CancellationToken cancellationToken) + { + var projectPath = Path.GetDirectoryName(project.FilePath); + if (projectPath is null) + { + return []; + } + + var projectEngine = RazorProjectInfoHelpers.GetProjectEngine(project, projectPath); + if (projectEngine is null) + { + return []; + } + + var tagHelpers = await _innerResolver + .GetTagHelpersAsync(project, projectEngine, cancellationToken) + .ConfigureAwait(false); + + using var builder = new PooledArrayBuilder(capacity: tagHelpers.Length); + + // Add each tag helpers to the cache so that we can retrieve them later if needed. + var cache = TagHelperCache.Default; + + foreach (var tagHelper in tagHelpers) + { + var checksum = tagHelper.Checksum; + builder.Add(checksum); + cache.TryAdd(checksum, tagHelper); + } + + return builder.DrainToImmutable(); + } +} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/ProjectSystem/ProjectWorkspaceState.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/ProjectSystem/ProjectWorkspaceState.cs index b4c2c9548fb..03bf3a7c3be 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/ProjectSystem/ProjectWorkspaceState.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/ProjectSystem/ProjectWorkspaceState.cs @@ -5,6 +5,7 @@ using System.Collections.Immutable; using System.Linq; using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.Utilities; using Microsoft.CodeAnalysis.CSharp; using Microsoft.Extensions.Internal; @@ -54,4 +55,13 @@ public override int GetHashCode() return hash.CombinedHash; } + + internal void CalculateChecksum(Checksum.Builder builder) + { + builder.AppendData((int)CSharpLanguageVersion); + foreach (var tagHelper in TagHelpers) + { + builder.AppendData(tagHelper.Checksum); + } + } } diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/ProjectSystem/RazorProjectInfo.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/ProjectSystem/RazorProjectInfo.cs index 7a05e789bce..3816621f619 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/ProjectSystem/RazorProjectInfo.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/ProjectSystem/RazorProjectInfo.cs @@ -6,6 +6,7 @@ using System.Linq; using Microsoft.AspNetCore.Razor.Language; using Microsoft.AspNetCore.Razor.Serialization; +using Microsoft.AspNetCore.Razor.Utilities; using Microsoft.CodeAnalysis; using Microsoft.Extensions.Internal; @@ -21,6 +22,10 @@ internal sealed record class RazorProjectInfo public ProjectWorkspaceState ProjectWorkspaceState { get; init; } public ImmutableArray Documents { get; init; } + private Checksum? _checksum; + internal Checksum Checksum + => _checksum ?? InterlockedOperations.Initialize(ref _checksum, ComputeChecksum()); + public RazorProjectInfo( ProjectKey projectKey, string filePath, @@ -63,4 +68,23 @@ public override int GetHashCode() return hash.CombinedHash; } + + private Checksum ComputeChecksum() + { + var builder = new Checksum.Builder(); + + builder.AppendData(FilePath); + builder.AppendData(ProjectKey.Id); + builder.AppendData(DisplayName); + + Configuration.CalculateChecksum(builder); + foreach (var document in Documents) + { + document.CalculateChecksum(builder); + } + + ProjectWorkspaceState.CalculateChecksum(builder); + + return builder.FreeAndGetChecksum(); + } } diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/RazorProjectInfoHelpers.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/RazorProjectInfoHelpers.cs index 573bb837edd..9af52531c87 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/RazorProjectInfoHelpers.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/RazorProjectInfoHelpers.cs @@ -33,20 +33,14 @@ static RazorProjectInfoHelpers() : StringComparison.OrdinalIgnoreCase; } - public static async Task ConvertAsync( + public static RazorProjectInfo? TryConvert( Project project, string projectPath, string intermediateOutputPath, - RazorConfiguration? razorConfiguration, - string? defaultNamespace, - ProjectWorkspaceState? projectWorkspaceState, - ImmutableArray documents, - CancellationToken cancellationToken) + ImmutableArray tagHelpers) { - if (documents.IsDefault) - { - documents = GetDocuments(project, projectPath); - } + + var documents = GetDocuments(project, projectPath); // Not a razor project if (documents.Length == 0) @@ -54,29 +48,20 @@ static RazorProjectInfoHelpers() return null; } - if (razorConfiguration is null) - { - var options = project.AnalyzerOptions.AnalyzerConfigOptionsProvider; - razorConfiguration = ComputeRazorConfigurationOptions(options, out defaultNamespace); - } + var options = project.AnalyzerOptions.AnalyzerConfigOptionsProvider; + var (razorConfiguration, rootNamespace) = ComputeRazorConfigurationOptions(options); - if (projectWorkspaceState is null) - { - projectWorkspaceState = await GetWorkspaceStateAsync(project, razorConfiguration, defaultNamespace, projectPath, cancellationToken).ConfigureAwait(false); - if (projectWorkspaceState is null) - { - return null; - } - } + var csharpLanguageVersion = (project.ParseOptions as CSharpParseOptions)?.LanguageVersion ?? LanguageVersion.Default; + var projectWorkspaceState = ProjectWorkspaceState.Create(tagHelpers, csharpLanguageVersion); return new RazorProjectInfo( projectKey: new ProjectKey(intermediateOutputPath), filePath: project.FilePath!, - configuration: razorConfiguration, - rootNamespace: defaultNamespace, + razorConfiguration, + rootNamespace, displayName: project.Name, - projectWorkspaceState: projectWorkspaceState, - documents: documents); + projectWorkspaceState, + documents); } public static async Task GetWorkspaceStateAsync(Project project, RazorConfiguration configuration, string? defaultNamespace, string projectPath, CancellationToken cancellationToken) @@ -111,14 +96,14 @@ static RazorProjectInfoHelpers() public static RazorProjectEngine? GetProjectEngine(Project project, string projectPath) { var options = project.AnalyzerOptions.AnalyzerConfigOptionsProvider; - var configuration = ComputeRazorConfigurationOptions(options, out var defaultNamespace); + var (configuration, rootNamespace) = ComputeRazorConfigurationOptions(options); var csharpLanguageVersion = (project.ParseOptions as CSharpParseOptions)?.LanguageVersion ?? LanguageVersion.Default; var fileSystem = RazorProjectFileSystem.Create(projectPath); var defaultConfigure = (RazorProjectEngineBuilder builder) => { - if (defaultNamespace is not null) + if (rootNamespace is not null) { - builder.SetRootNamespace(defaultNamespace); + builder.SetRootNamespace(rootNamespace); } builder.SetCSharpLanguageVersion(csharpLanguageVersion); @@ -133,7 +118,7 @@ static RazorProjectInfoHelpers() configure: defaultConfigure); } - public static RazorConfiguration ComputeRazorConfigurationOptions(AnalyzerConfigOptionsProvider options, out string defaultNamespace) + public static (RazorConfiguration razorConfiguration, string rootNamespace) ComputeRazorConfigurationOptions(AnalyzerConfigOptionsProvider options) { // See RazorSourceGenerator.RazorProviders.cs @@ -153,9 +138,9 @@ public static RazorConfiguration ComputeRazorConfigurationOptions(AnalyzerConfig var razorConfiguration = new RazorConfiguration(razorLanguageVersion, configurationName, Extensions: [], UseConsolidatedMvcViews: true); - defaultNamespace = rootNamespace ?? "ASP"; // TODO: Source generator does this. Do we want it? + rootNamespace ??= "ASP"; // TODO: Source generator does this. Do we want it? - return razorConfiguration; + return (razorConfiguration, rootNamespace); } public static ImmutableArray GetDocuments(Project project, string projectPath) diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/Serialization/DocumentSnapshotHandle.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/Serialization/DocumentSnapshotHandle.cs index 9b4bafb1f27..a2a36baeab3 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/Serialization/DocumentSnapshotHandle.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/Serialization/DocumentSnapshotHandle.cs @@ -1,6 +1,17 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT license. See License.txt in the project root for license information. +using Microsoft.AspNetCore.Razor.Utilities; + namespace Microsoft.AspNetCore.Razor.Serialization; -internal record DocumentSnapshotHandle(string FilePath, string TargetPath, string FileKind); +internal record DocumentSnapshotHandle(string FilePath, string TargetPath, string FileKind) +{ + internal void CalculateChecksum(Checksum.Builder builder) + { + + builder.AppendData(FilePath); + builder.AppendData(TargetPath); + builder.AppendData(FileKind); + } +} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/Serialization/RazorMessagePackSerializer.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/Serialization/RazorMessagePackSerializer.cs index 0d7cf7291dd..9cd682081f8 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/Serialization/RazorMessagePackSerializer.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/Serialization/RazorMessagePackSerializer.cs @@ -11,6 +11,7 @@ using System.Threading; namespace Microsoft.AspNetCore.Razor.Serialization; + internal static class RazorMessagePackSerializer { private static readonly MessagePackSerializerOptions s_options = MessagePackSerializerOptions.Standard diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Remote/TagHelperResultCache.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/TagHelperResultCache.cs similarity index 96% rename from src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Remote/TagHelperResultCache.cs rename to src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/TagHelperResultCache.cs index c24e623bf4f..5a450bfb4c0 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Remote/TagHelperResultCache.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/TagHelperResultCache.cs @@ -3,8 +3,9 @@ using System.Collections.Immutable; using Microsoft.AspNetCore.Razor.Utilities; +using Microsoft.CodeAnalysis; -namespace Microsoft.CodeAnalysis.Razor.Remote; +namespace Microsoft.AspNetCore.Razor; internal class TagHelperResultCache { diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/Utilities/StreamExtensions.NetCore.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/Utilities/StreamExtensions.NetCore.cs index 81abc7a2a16..e27a217e39d 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/Utilities/StreamExtensions.NetCore.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/Utilities/StreamExtensions.NetCore.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. See License.txt in the project root for license information. using Microsoft.AspNetCore.Razor.ProjectSystem; +using Microsoft.AspNetCore.Razor.Serialization; using System; using System.Buffers; using System.Diagnostics; @@ -73,9 +74,15 @@ public static Task ReadProjectInfoRemovalAsync(this Stream stream, Cance return stream.ReadStringAsync(encoding: null, cancellationToken); } + public static async Task WriteWithSizeAsync(this Stream stream, T value, CancellationToken cancellationToken) + { + var bytes = RazorMessagePackSerializer.Serialize(value); + WriteSize(stream, bytes.Length); + await stream.WriteAsync(bytes, 0, bytes.Length, cancellationToken).ConfigureAwait(false); + } public static async Task WriteProjectInfoAsync(this Stream stream, RazorProjectInfo projectInfo, CancellationToken cancellationToken) { - var bytes = projectInfo.Serialize(); + var bytes = RazorMessagePackSerializer.Serialize(projectInfo); WriteSize(stream, bytes.Length); await stream.WriteAsync(bytes, 0, bytes.Length, cancellationToken).ConfigureAwait(false); } @@ -90,7 +97,7 @@ public static async Task WriteProjectInfoAsync(this Stream stream, RazorProjectI // The array may be larger than the bytes read so make sure to trim accordingly. var projectInfoMemory = projectInfoBytes.AsMemory(0, sizeToRead); - return RazorProjectInfo.DeserializeFrom(projectInfoMemory); + return RazorMessagePackSerializer.DeserializeFrom(projectInfoMemory); } public static void WriteSize(this Stream stream, int length) diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/TagHelpers/RemoteTagHelperDeltaProvider.cs b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/TagHelpers/RemoteTagHelperDeltaProvider.cs index 849a7f21263..64ae73dae28 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/TagHelpers/RemoteTagHelperDeltaProvider.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/TagHelpers/RemoteTagHelperDeltaProvider.cs @@ -3,9 +3,9 @@ using System.Collections.Immutable; using System.Composition; +using Microsoft.AspNetCore.Razor; using Microsoft.AspNetCore.Razor.Serialization; using Microsoft.AspNetCore.Razor.Utilities; -using Microsoft.CodeAnalysis.Razor.Remote; namespace Microsoft.CodeAnalysis.Remote.Razor; diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.Test/RazorWorkspaceListenerTest.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.Test/RazorWorkspaceListenerTest.cs index 8fe48a38dce..d57248e9043 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.Test/RazorWorkspaceListenerTest.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.Test/RazorWorkspaceListenerTest.cs @@ -256,6 +256,57 @@ public async Task TestSerialization() Assert.Equal(intermediateDirectory, await readerStream.ReadProjectInfoRemovalAsync(CancellationToken.None)); } + [Fact] + public async Task CSharpDocumentAdded_DoesNotUpdate() + { + using var workspace = new AdhocWorkspace(CodeAnalysis.Host.Mef.MefHostServices.DefaultHost); + + using var listener = new TestRazorWorkspaceListener(); + listener.EnsureInitialized(workspace); + + var project = workspace.AddProject("TestProject", LanguageNames.CSharp); + listener.NotifyDynamicFile(project.Id); + + await listener.WaitForDebounceAsync(); + + Assert.Equal(1, listener.SerializeCalls[project.Id]); + Assert.Equal(1, listener.WorkspaceChangedEvents); + + var document = project.AddDocument("TestDocument", "class TestDocument { }"); + Assert.True(workspace.TryApplyChanges(document.Project.Solution)); + + // We can't wait for debounce here, because it won't happen, but if we don't wait for _something_ we won't know + // if the test fails, so a delay is annoyingly necessary. + await Task.Delay(500); + + Assert.Equal(2, listener.WorkspaceChangedEvents); + Assert.Equal(1, listener.SerializeCalls[project.Id]); + } + + [Fact] + public async Task RazorFileAdded_DoesUpdate() + { + using var workspace = new AdhocWorkspace(CodeAnalysis.Host.Mef.MefHostServices.DefaultHost); + + using var listener = new TestRazorWorkspaceListener(); + listener.EnsureInitialized(workspace); + + var project = workspace.AddProject("TestProject", LanguageNames.CSharp); + listener.NotifyDynamicFile(project.Id); + + await listener.WaitForDebounceAsync(); + + Assert.Equal(1, listener.SerializeCalls[project.Id]); + Assert.Equal(1, listener.WorkspaceChangedEvents); + + workspace.AddDocument(DocumentInfo.Create(DocumentId.CreateNewId(project.Id), @"Page.razor", filePath: @"C:\test\Page.razor")); + + await listener.WaitForDebounceAsync(); + + Assert.Equal(2, listener.WorkspaceChangedEvents); + Assert.Equal(2, listener.SerializeCalls[project.Id]); + } + private class TestRazorWorkspaceListener : RazorWorkspaceListenerBase { private ConcurrentDictionary _serializeCalls = new(); @@ -265,6 +316,7 @@ private class TestRazorWorkspaceListener : RazorWorkspaceListenerBase public ConcurrentDictionary SerializeCalls => _serializeCalls; public ConcurrentDictionary RemoveCalls => _removeCalls; + public int WorkspaceChangedEvents { get; private set; } public TestRazorWorkspaceListener() : base(NullLoggerFactory.Instance.CreateLogger("")) @@ -274,6 +326,7 @@ public TestRazorWorkspaceListener() public void EnsureInitialized(Workspace workspace) { EnsureInitialized(workspace, static () => Stream.Null); + workspace.WorkspaceChanged += (s, a) => { WorkspaceChangedEvents++; }; } private protected override ValueTask ProcessWorkAsync(ImmutableArray work, CancellationToken cancellationToken) From 4693b2ceefc37ecabc39a1e00d7c01dd86d9e9fe Mon Sep 17 00:00:00 2001 From: Andrew Hall Date: Thu, 29 Aug 2024 15:39:58 -0700 Subject: [PATCH 04/15] Spacing --- .../Utilities/StreamExtensions.NetCore.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/Utilities/StreamExtensions.NetCore.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/Utilities/StreamExtensions.NetCore.cs index e27a217e39d..855306bca98 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/Utilities/StreamExtensions.NetCore.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/Utilities/StreamExtensions.NetCore.cs @@ -80,6 +80,7 @@ public static async Task WriteWithSizeAsync(this Stream stream, T value, Canc WriteSize(stream, bytes.Length); await stream.WriteAsync(bytes, 0, bytes.Length, cancellationToken).ConfigureAwait(false); } + public static async Task WriteProjectInfoAsync(this Stream stream, RazorProjectInfo projectInfo, CancellationToken cancellationToken) { var bytes = RazorMessagePackSerializer.Serialize(projectInfo); From 3179c68533855dad2992f26c87b40773a6d3586a Mon Sep 17 00:00:00 2001 From: Andrew Hall Date: Thu, 29 Aug 2024 16:17:21 -0700 Subject: [PATCH 05/15] Fix bug and add tests --- .../src/Language/RazorConfiguration.cs | 13 +++ .../ProjectSystem/RazorProjectInfo.cs | 1 + .../ProjectInfoChecksumTests.cs | 97 +++++++++++++++++++ .../RazorConfigurationChecksumTests.cs | 65 +++++++++++++ 4 files changed, 176 insertions(+) create mode 100644 src/Razor/test/Microsoft.AspNetCore.Razor.ProjectEngineHost.Test/ProjectInfoChecksumTests.cs create mode 100644 src/Razor/test/Microsoft.AspNetCore.Razor.ProjectEngineHost.Test/RazorConfigurationChecksumTests.cs diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorConfiguration.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorConfiguration.cs index 6172a83b514..3bc889c05c7 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorConfiguration.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorConfiguration.cs @@ -2,8 +2,10 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; +using System.Threading; using Microsoft.AspNetCore.Razor.Utilities; using Microsoft.Extensions.Internal; @@ -23,6 +25,10 @@ public sealed record class RazorConfiguration( LanguageServerFlags: null, UseConsolidatedMvcViews: true); + private Checksum? _checksum; + internal Checksum Checksum + => _checksum ?? InterlockedOperations.Initialize(ref _checksum, CalculateChecksum()); + public bool Equals(RazorConfiguration? other) => other is not null && LanguageVersion == other.LanguageVersion && @@ -54,4 +60,11 @@ internal void CalculateChecksum(Checksum.Builder builder) builder.AppendData(extension.ExtensionName); } } + + private Checksum CalculateChecksum() + { + var builder = new Checksum.Builder(); + CalculateChecksum(builder); + return builder.FreeAndGetChecksum(); + } } diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/ProjectSystem/RazorProjectInfo.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/ProjectSystem/RazorProjectInfo.cs index 3816621f619..bdd4aaa994b 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/ProjectSystem/RazorProjectInfo.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/ProjectSystem/RazorProjectInfo.cs @@ -76,6 +76,7 @@ private Checksum ComputeChecksum() builder.AppendData(FilePath); builder.AppendData(ProjectKey.Id); builder.AppendData(DisplayName); + builder.AppendData(RootNamespace); Configuration.CalculateChecksum(builder); foreach (var document in Documents) diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.ProjectEngineHost.Test/ProjectInfoChecksumTests.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.ProjectEngineHost.Test/ProjectInfoChecksumTests.cs new file mode 100644 index 00000000000..66cfd0297ac --- /dev/null +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.ProjectEngineHost.Test/ProjectInfoChecksumTests.cs @@ -0,0 +1,97 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT license. See License.txt in the project root for license information. + +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.ProjectSystem; +using Microsoft.AspNetCore.Razor.Serialization; +using Microsoft.AspNetCore.Razor.Test.Common; +using Xunit; + +namespace Microsoft.AspNetCore.Razor.ProjectEngineHost.Test; + +public class ProjectInfoChecksumTests +{ + [Fact] + public void CheckSame() + { + var info1 = CreateInfo(); + var info2 = CreateInfo(); + + Assert.Equal(info1.Checksum, info2.Checksum); + } + + [Fact] + public void Change_ProjectKey() + { + var info1 = CreateInfo(); + var info2 = info1 with { ProjectKey = new ProjectKey("Test2") }; + + Assert.NotEqual(info1.Checksum, info2.Checksum); + } + + [Fact] + public void Change_FilePath() + { + var info1 = CreateInfo(); + var info2 = info1 with { FilePath = @"C:\test\test2.csproj" }; + + Assert.NotEqual(info1.Checksum, info2.Checksum); + } + + [Fact] + public void Change_RootNamespace() + { + var info1 = CreateInfo(); + var info2 = info1 with { RootNamespace = "TestNamespace2" }; + + Assert.NotEqual(info1.Checksum, info2.Checksum); + } + + [Fact] + public void Change_DisplayName() + { + var info1 = CreateInfo(); + var info2 = info1 with { DisplayName = "Test2 (tfm)" }; + + Assert.NotEqual(info1.Checksum, info2.Checksum); + } + + [Fact] + public void Change_Configuration() + { + var info1 = CreateInfo(); + var info2 = info1 with { Configuration = new RazorConfiguration(RazorLanguageVersion.Latest, "TestConfiguration2", []) }; + + Assert.NotEqual(info1.Checksum, info2.Checksum); + } + + [Fact] + public void Change_ProjectWorkspaceState() + { + var info1 = CreateInfo(); + var info2 = info1 with { ProjectWorkspaceState = ProjectWorkspaceState.Create(RazorTestResources.BlazorServerAppTagHelpers, CodeAnalysis.CSharp.LanguageVersion.CSharp10) }; + + Assert.NotEqual(info1.Checksum, info2.Checksum); + } + + [Fact] + public void Change_Documents() + { + var info1 = CreateInfo(); + var info2 = info1 with { Documents = info1.Documents.Add(new DocumentSnapshotHandle(@"C:\test\home.razor", @"C:\test\lib\net8.0", FileKinds.Component)) }; + + Assert.NotEqual(info1.Checksum, info2.Checksum); + } + + RazorProjectInfo CreateInfo() + { + return new RazorProjectInfo( + new ProjectKey("Test"), + @"C:\test\test.csproj", + new RazorConfiguration(RazorLanguageVersion.Latest, "TestConfiguration", []), + "TestNamespace", + "Test (tfm)", + ProjectWorkspaceState.Create(RazorTestResources.BlazorServerAppTagHelpers, CodeAnalysis.CSharp.LanguageVersion.Latest), + []); + } +} diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.ProjectEngineHost.Test/RazorConfigurationChecksumTests.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.ProjectEngineHost.Test/RazorConfigurationChecksumTests.cs new file mode 100644 index 00000000000..67845df4c5c --- /dev/null +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.ProjectEngineHost.Test/RazorConfigurationChecksumTests.cs @@ -0,0 +1,65 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT license. See License.txt in the project root for license information. + +using Microsoft.AspNetCore.Razor.Language; +using Xunit; + +namespace Microsoft.AspNetCore.Razor.ProjectEngineHost.Test; + +public class RazorConfigurationChecksumTests +{ + [Fact] + public void CheckSame() + { + var config1 = GetConfiguration(); + var config2 = GetConfiguration(); + + Assert.Equal(config1.Checksum, config2.Checksum); + } + + [Fact] + public void Change_RazorLanguageVersion() + { + var config1 = GetConfiguration(); + var config2 = config1 with { LanguageVersion = RazorLanguageVersion.Version_2_1 }; + + Assert.NotEqual(config1.Checksum, config2.Checksum); + } + + [Fact] + public void Change_ConfigurationName() + { + var config1 = GetConfiguration(); + var config2 = config1 with { ConfigurationName = "Configuration2" }; + + Assert.NotEqual(config1.Checksum, config2.Checksum); + } + + [Fact] + public void Change_Extensions() + { + var config1 = GetConfiguration(); + var config2 = config1 with { Extensions = config1.Extensions.Add(new RazorExtension("TestExtension2")) }; + + Assert.NotEqual(config1.Checksum, config2.Checksum); + } + + [Fact] + public void Change_UseConsolidatedMvcViews() + { + var config1 = GetConfiguration(); + var config2 = config1 with { UseConsolidatedMvcViews = !config1.UseConsolidatedMvcViews }; + + Assert.NotEqual(config1.Checksum, config2.Checksum); + } + + private RazorConfiguration GetConfiguration() + { + return new RazorConfiguration( + RazorLanguageVersion.Latest, + "Configuration", + [new RazorExtension("TestExtension")], + new LanguageServerFlags(ForceRuntimeCodeGeneration: true), + UseConsolidatedMvcViews: false); + } +} From 54d5a077aa746e73d33389c6e7cdb01ddf17b9ef Mon Sep 17 00:00:00 2001 From: Andrew Hall Date: Thu, 29 Aug 2024 16:34:07 -0700 Subject: [PATCH 06/15] PR feedback from https://github.com/dotnet/razor/pull/10756 --- .../CachedTagHelperResolver.cs | 4 ++-- .../RazorProjectInfoHelpers.cs | 17 ++++------------- .../Serialization/RazorMessagePackSerializer.cs | 11 +++++++---- 3 files changed, 13 insertions(+), 19 deletions(-) diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/CachedTagHelperResolver.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/CachedTagHelperResolver.cs index 476c0c598af..e90130789d5 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/CachedTagHelperResolver.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/CachedTagHelperResolver.cs @@ -29,7 +29,7 @@ public ImmutableArray GetValues(ProjectId projectKey, int r return []; } - using var _ = ArrayBuilderPool.GetPooledObject(out var builder); + using var builder = new PooledArrayBuilder(); foreach (var checksum in cachedChecksums) { var value = TryGet(checksum); @@ -41,7 +41,7 @@ public ImmutableArray GetValues(ProjectId projectKey, int r builder.Add(value); } - return builder.ToImmutableArray(); + return builder.DrainToImmutable(); } public async ValueTask GetDeltaAsync(Project project, int? lastResultIdNullable, CancellationToken cancellationToken) diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/RazorProjectInfoHelpers.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/RazorProjectInfoHelpers.cs index 9af52531c87..960a4bade2b 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/RazorProjectInfoHelpers.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/RazorProjectInfoHelpers.cs @@ -24,15 +24,6 @@ namespace Microsoft.AspNetCore.Razor; internal static class RazorProjectInfoHelpers { - private static readonly StringComparison s_stringComparison; - - static RazorProjectInfoHelpers() - { - s_stringComparison = RuntimeInformation.IsOSPlatform(OSPlatform.Linux) - ? StringComparison.Ordinal - : StringComparison.OrdinalIgnoreCase; - } - public static RazorProjectInfo? TryConvert( Project project, string projectPath, @@ -181,7 +172,7 @@ public static ImmutableArray GetDocuments(Project projec private static string GetTargetPath(string documentFilePath, string normalizedProjectPath) { var targetFilePath = FilePathNormalizer.Normalize(documentFilePath); - if (targetFilePath.StartsWith(normalizedProjectPath, s_stringComparison)) + if (targetFilePath.StartsWith(normalizedProjectPath, FilePathComparison.Instance)) { // Make relative targetFilePath = documentFilePath[normalizedProjectPath.Length..]; @@ -197,12 +188,12 @@ private static bool TryGetFileKind(string filePath, [NotNullWhen(true)] out stri { var extension = Path.GetExtension(filePath); - if (extension.Equals(".cshtml", s_stringComparison)) + if (extension.Equals(".cshtml", FilePathComparison.Instance)) { fileKind = FileKinds.Legacy; return true; } - else if (extension.Equals(".razor", s_stringComparison)) + else if (extension.Equals(".razor", FilePathComparison.Instance)) { fileKind = FileKinds.GetComponentFileKindFromFilePath(filePath); return true; @@ -228,7 +219,7 @@ private static bool TryGetRazorFileName(string? filePath, [NotNullWhen(true)] ou // Generated files have a path like: virtualcsharp-razor:///e:/Scratch/RazorInConsole/Goo.cshtml__virtual.cs if (filePath.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) && - (filePath.EndsWith(generatedRazorExtension, s_stringComparison) || filePath.EndsWith(generatedCshtmlExtension, s_stringComparison))) + (filePath.EndsWith(generatedRazorExtension, FilePathComparison.Instance) || filePath.EndsWith(generatedCshtmlExtension, FilePathComparison.Instance))) { // Go through the file path normalizer because it also does Uri decoding, and we're converting from a Uri to a path // but "new Uri(filePath).LocalPath" seems wasteful diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/Serialization/RazorMessagePackSerializer.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/Serialization/RazorMessagePackSerializer.cs index 9cd682081f8..d708d6500bb 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/Serialization/RazorMessagePackSerializer.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/Serialization/RazorMessagePackSerializer.cs @@ -2,13 +2,13 @@ // Licensed under the MIT license. See License.txt in the project root for license information. using System; -using System.Threading.Tasks; -using MessagePack.Resolvers; -using MessagePack; -using Microsoft.AspNetCore.Razor.Serialization.MessagePack.Resolvers; using System.Buffers; using System.IO; using System.Threading; +using System.Threading.Tasks; +using MessagePack; +using MessagePack.Resolvers; +using Microsoft.AspNetCore.Razor.Serialization.MessagePack.Resolvers; namespace Microsoft.AspNetCore.Razor.Serialization; @@ -31,6 +31,9 @@ public static void SerializeTo(T instance, Stream stream) public static T? DeserializeFrom(ReadOnlyMemory buffer) => MessagePackSerializer.Deserialize(buffer, s_options); + public static T? DeserializeFrom(ReadOnlySequence buffer) + => MessagePackSerializer.Deserialize(buffer, s_options); + public static T? DeserializeFrom(Stream stream) => MessagePackSerializer.Deserialize(stream, s_options); From 1f9d9532121f96776e4024684d2df18a64c272d0 Mon Sep 17 00:00:00 2001 From: Andrew Hall Date: Fri, 30 Aug 2024 15:52:32 -0700 Subject: [PATCH 07/15] Update src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/RazorProjectInfoHelpers.cs Co-authored-by: Dustin Campbell --- .../RazorProjectInfoHelpers.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/RazorProjectInfoHelpers.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/RazorProjectInfoHelpers.cs index 960a4bade2b..fcb40bb0fa4 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/RazorProjectInfoHelpers.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/RazorProjectInfoHelpers.cs @@ -47,7 +47,7 @@ internal static class RazorProjectInfoHelpers return new RazorProjectInfo( projectKey: new ProjectKey(intermediateOutputPath), - filePath: project.FilePath!, + filePath: project.FilePath.AssumeNotNull(), razorConfiguration, rootNamespace, displayName: project.Name, From 14f5d71f6bdf8446869f6a404bf680ce335d510e Mon Sep 17 00:00:00 2001 From: Andrew Hall Date: Fri, 30 Aug 2024 16:23:37 -0700 Subject: [PATCH 08/15] PR Feedback --- .../src/Language/RazorConfiguration.cs | 13 ++++++-- .../RazorWorkspaceListenerBase.cs | 3 +- .../ProjectSystem/ProjectWorkspaceState.cs | 2 +- .../ProjectSystem/RazorProjectInfo.cs | 31 ++++--------------- .../Serialization/DocumentSnapshotHandle.cs | 3 +- 5 files changed, 21 insertions(+), 31 deletions(-) diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorConfiguration.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorConfiguration.cs index 3bc889c05c7..70356d7d1fa 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorConfiguration.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorConfiguration.cs @@ -48,13 +48,22 @@ public override int GetHashCode() return hash; } - internal void CalculateChecksum(Checksum.Builder builder) + internal void AppendChecksum(Checksum.Builder builder) { builder.AppendData(LanguageVersion.Major); builder.AppendData(LanguageVersion.Minor); builder.AppendData(ConfigurationName); builder.AppendData(UseConsolidatedMvcViews); + if (LanguageServerFlags is null) + { + builder.AppendNull(); + } + else + { + builder.AppendData(LanguageServerFlags.ForceRuntimeCodeGeneration); + } + foreach (var extension in Extensions) { builder.AppendData(extension.ExtensionName); @@ -64,7 +73,7 @@ internal void CalculateChecksum(Checksum.Builder builder) private Checksum CalculateChecksum() { var builder = new Checksum.Builder(); - CalculateChecksum(builder); + AppendChecksum(builder); return builder.FreeAndGetChecksum(); } } diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListenerBase.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListenerBase.cs index 48542f31739..e0799934f2f 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListenerBase.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListenerBase.cs @@ -19,7 +19,8 @@ public abstract partial class RazorWorkspaceListenerBase : IDisposable private readonly ILogger _logger; private readonly AsyncBatchingWorkQueue _workQueue; private readonly CachedTagHelperResolver _cachedTagHelperResolver = new(NoOpTelemetryReporter.Instance); - private readonly Dictionary> _documentSnapshots = new(); + + // Only modified in the batching work queue so no need to lock for mutation private readonly Dictionary _projectEntryMap = new(); // Use an immutable dictionary for ImmutableInterlocked operations. The value isn't checked, just diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/ProjectSystem/ProjectWorkspaceState.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/ProjectSystem/ProjectWorkspaceState.cs index 03bf3a7c3be..b775aea7491 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/ProjectSystem/ProjectWorkspaceState.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/ProjectSystem/ProjectWorkspaceState.cs @@ -56,7 +56,7 @@ public override int GetHashCode() return hash.CombinedHash; } - internal void CalculateChecksum(Checksum.Builder builder) + internal void AppendChecksum(Checksum.Builder builder) { builder.AppendData((int)CSharpLanguageVersion); foreach (var tagHelper in TagHelpers) diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/ProjectSystem/RazorProjectInfo.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/ProjectSystem/RazorProjectInfo.cs index bdd4aaa994b..c6230d42538 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/ProjectSystem/RazorProjectInfo.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/ProjectSystem/RazorProjectInfo.cs @@ -44,30 +44,11 @@ public RazorProjectInfo( Documents = documents.NullToEmpty(); } - public bool Equals(RazorProjectInfo? other) - => other is not null && - ProjectKey == other.ProjectKey && - FilePath == other.FilePath && - Configuration.Equals(other.Configuration) && - RootNamespace == other.RootNamespace && - DisplayName == other.DisplayName && - ProjectWorkspaceState.Equals(other.ProjectWorkspaceState) && - Documents.SequenceEqual(other.Documents); + public bool Equals(RazorConfiguration? other) + => other is not null && Checksum.Equals(other.Checksum); public override int GetHashCode() - { - var hash = HashCodeCombiner.Start(); - - hash.Add(ProjectKey); - hash.Add(FilePath); - hash.Add(Configuration); - hash.Add(RootNamespace); - hash.Add(DisplayName); - hash.Add(ProjectWorkspaceState); - hash.Add(Documents); - - return hash.CombinedHash; - } + => Checksum.GetHashCode(); private Checksum ComputeChecksum() { @@ -78,13 +59,13 @@ private Checksum ComputeChecksum() builder.AppendData(DisplayName); builder.AppendData(RootNamespace); - Configuration.CalculateChecksum(builder); + Configuration.AppendChecksum(builder); foreach (var document in Documents) { - document.CalculateChecksum(builder); + document.AppendChecksum(builder); } - ProjectWorkspaceState.CalculateChecksum(builder); + ProjectWorkspaceState.AppendChecksum(builder); return builder.FreeAndGetChecksum(); } diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/Serialization/DocumentSnapshotHandle.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/Serialization/DocumentSnapshotHandle.cs index a2a36baeab3..f848b283680 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/Serialization/DocumentSnapshotHandle.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/Serialization/DocumentSnapshotHandle.cs @@ -7,9 +7,8 @@ namespace Microsoft.AspNetCore.Razor.Serialization; internal record DocumentSnapshotHandle(string FilePath, string TargetPath, string FileKind) { - internal void CalculateChecksum(Checksum.Builder builder) + internal void AppendChecksum(Checksum.Builder builder) { - builder.AppendData(FilePath); builder.AppendData(TargetPath); builder.AppendData(FileKind); From 0dd6a2c4941ea2e48e5024e0b299ef763d206030 Mon Sep 17 00:00:00 2001 From: Andrew Hall Date: Tue, 3 Sep 2024 11:32:59 -0700 Subject: [PATCH 09/15] USINGS.... --- .../ProjectSystem/RazorProjectInfo.cs | 3 --- .../RazorProjectInfoHelpers.cs | 1 - 2 files changed, 4 deletions(-) diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/ProjectSystem/RazorProjectInfo.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/ProjectSystem/RazorProjectInfo.cs index c6230d42538..d38ddcd1d11 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/ProjectSystem/RazorProjectInfo.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/ProjectSystem/RazorProjectInfo.cs @@ -1,14 +1,11 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT license. See License.txt in the project root for license information. -using System; using System.Collections.Immutable; -using System.Linq; using Microsoft.AspNetCore.Razor.Language; using Microsoft.AspNetCore.Razor.Serialization; using Microsoft.AspNetCore.Razor.Utilities; using Microsoft.CodeAnalysis; -using Microsoft.Extensions.Internal; namespace Microsoft.AspNetCore.Razor.ProjectSystem; diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/RazorProjectInfoHelpers.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/RazorProjectInfoHelpers.cs index fcb40bb0fa4..e2a1b09bcdb 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/RazorProjectInfoHelpers.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/RazorProjectInfoHelpers.cs @@ -5,7 +5,6 @@ using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; using System.IO; -using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Razor.Language; From aeebf20770c4b9242b2574c2d1dfedcd58154992 Mon Sep 17 00:00:00 2001 From: Andrew Hall Date: Tue, 3 Sep 2024 16:41:53 -0700 Subject: [PATCH 10/15] PR feedback --- .../RazorWorkspaceListenerBase.cs | 9 +---- .../ProjectExtensions.cs | 38 +++++++++++++++++++ .../RazorProjectInfoHelpers.cs | 4 +- .../ProjectSystem/Extensions.cs | 29 -------------- .../ProjectSystem/FallbackProjectManager.cs | 1 + ...tionUpdatesProjectSnapshotChangeTrigger.cs | 1 + 6 files changed, 42 insertions(+), 40 deletions(-) create mode 100644 src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/ProjectExtensions.cs diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListenerBase.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListenerBase.cs index e0799934f2f..fab61aeb27b 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListenerBase.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListenerBase.cs @@ -288,20 +288,13 @@ private async Task ReportUpdateProjectAsync(Stream stream, Project project, Canc return; } - var intermediateOutputPath = Path.GetDirectoryName(project.CompilationOutputInfo.AssemblyPath); - if (intermediateOutputPath is null) - { - _logger.LogInformation("intermediateOutputPath is null, skip update for {projectId}", project.Id); - return; - } - var entry = _projectEntryMap.GetOrAdd(project.Id, static _ => new ProjectEntry()); var delta = await _cachedTagHelperResolver.GetDeltaAsync(project, entry.TagHelpersResultId, cancellationToken).ConfigureAwait(false); entry.TagHelpersResultId = delta.ResultId; var tagHelpers = _cachedTagHelperResolver.GetValues(project.Id, delta.ResultId); - var projectInfo = RazorProjectInfoHelpers.TryConvert(project, projectPath, intermediateOutputPath, tagHelpers); + var projectInfo = RazorProjectInfoHelpers.TryConvert(project, projectPath, tagHelpers); if (projectInfo is not null) { var checkSum = projectInfo.Checksum; diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/ProjectExtensions.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/ProjectExtensions.cs new file mode 100644 index 00000000000..5bff1fb7f44 --- /dev/null +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/ProjectExtensions.cs @@ -0,0 +1,38 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT license. See License.txt in the project root for license information. + +#if !NET +using System; +#endif + +using System.Diagnostics; +using Microsoft.AspNetCore.Razor.ProjectSystem; +using Microsoft.AspNetCore.Razor.Utilities; +using Microsoft.CodeAnalysis; + +namespace Microsoft.AspNetCore.Razor; + +internal static class ProjectExtensions +{ + public static ProjectKey ToProjectKey(this Project project) + { + var intermediateOutputPath = FilePathNormalizer.GetNormalizedDirectoryName(project.CompilationOutputInfo.AssemblyPath); + return new(intermediateOutputPath); + } + + /// + /// Returns if this matches the given . + /// + public static bool Matches(this ProjectKey projectKey, Project project) + { + // In order to perform this check, we are relying on the fact that Id will always end with a '/', + // because it is guaranteed to be normalized. However, CompilationOutputInfo.AssemblyPath will + // contain the assembly file name, which AreDirectoryPathsEquivalent will shave off before comparing. + // So, AreDirectoryPathsEquivalent will return true when Id is "C:/my/project/path/" + // and the assembly path is "C:\my\project\path\assembly.dll" + + Debug.Assert(projectKey.Id.EndsWith('/'), $"This method can't be called if {nameof(projectKey.Id)} is not a normalized directory path."); + + return FilePathNormalizer.AreDirectoryPathsEquivalent(projectKey.Id, project.CompilationOutputInfo.AssemblyPath); + } +} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/RazorProjectInfoHelpers.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/RazorProjectInfoHelpers.cs index e2a1b09bcdb..00a3b768a4b 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/RazorProjectInfoHelpers.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/RazorProjectInfoHelpers.cs @@ -26,10 +26,8 @@ internal static class RazorProjectInfoHelpers public static RazorProjectInfo? TryConvert( Project project, string projectPath, - string intermediateOutputPath, ImmutableArray tagHelpers) { - var documents = GetDocuments(project, projectPath); // Not a razor project @@ -45,7 +43,7 @@ internal static class RazorProjectInfoHelpers var projectWorkspaceState = ProjectWorkspaceState.Create(tagHelpers, csharpLanguageVersion); return new RazorProjectInfo( - projectKey: new ProjectKey(intermediateOutputPath), + projectKey: project.ToProjectKey(), filePath: project.FilePath.AssumeNotNull(), razorConfiguration, rootNamespace, diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/Extensions.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/Extensions.cs index 34b050477a0..f27ab8f9a04 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/Extensions.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/Extensions.cs @@ -1,15 +1,8 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT license. See License.txt in the project root for license information. -#if !NET -using System; -#endif - -using System.Diagnostics; using Microsoft.AspNetCore.Razor; -using Microsoft.AspNetCore.Razor.ProjectSystem; using Microsoft.AspNetCore.Razor.Serialization; -using Microsoft.AspNetCore.Razor.Utilities; namespace Microsoft.CodeAnalysis.Razor.ProjectSystem; @@ -17,26 +10,4 @@ internal static class Extensions { public static DocumentSnapshotHandle ToHandle(this IDocumentSnapshot snapshot) => new(snapshot.FilePath.AssumeNotNull(), snapshot.TargetPath.AssumeNotNull(), snapshot.FileKind.AssumeNotNull()); - - public static ProjectKey ToProjectKey(this Project project) - { - var intermediateOutputPath = FilePathNormalizer.GetNormalizedDirectoryName(project.CompilationOutputInfo.AssemblyPath); - return new(intermediateOutputPath); - } - - /// - /// Returns if this matches the given . - /// - public static bool Matches(this ProjectKey projectKey, Project project) - { - // In order to perform this check, we are relying on the fact that Id will always end with a '/', - // because it is guaranteed to be normalized. However, CompilationOutputInfo.AssemblyPath will - // contain the assembly file name, which AreDirectoryPathsEquivalent will shave off before comparing. - // So, AreDirectoryPathsEquivalent will return true when Id is "C:/my/project/path/" - // and the assembly path is "C:\my\project\path\assembly.dll" - - Debug.Assert(projectKey.Id.EndsWith('/'), $"This method can't be called if {nameof(projectKey.Id)} is not a normalized directory path."); - - return FilePathNormalizer.AreDirectoryPathsEquivalent(projectKey.Id, project.CompilationOutputInfo.AssemblyPath); - } } diff --git a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/ProjectSystem/FallbackProjectManager.cs b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/ProjectSystem/FallbackProjectManager.cs index 09130e8cb44..2803a39d311 100644 --- a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/ProjectSystem/FallbackProjectManager.cs +++ b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/ProjectSystem/FallbackProjectManager.cs @@ -5,6 +5,7 @@ using System.ComponentModel.Composition; using System.IO; using System.Threading; +using Microsoft.AspNetCore.Razor; using Microsoft.AspNetCore.Razor.ProjectSystem; using Microsoft.AspNetCore.Razor.Telemetry; using Microsoft.AspNetCore.Razor.Utilities; diff --git a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/VsSolutionUpdatesProjectSnapshotChangeTrigger.cs b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/VsSolutionUpdatesProjectSnapshotChangeTrigger.cs index c1e9de5988f..55ba43f16f7 100644 --- a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/VsSolutionUpdatesProjectSnapshotChangeTrigger.cs +++ b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/VsSolutionUpdatesProjectSnapshotChangeTrigger.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Runtime.InteropServices; using System.Threading; +using Microsoft.AspNetCore.Razor; using Microsoft.CodeAnalysis.Razor.ProjectSystem; using Microsoft.CodeAnalysis.Razor.Workspaces; using Microsoft.VisualStudio.Razor.Extensions; From c64d1108a219ea7a63bc1eca86e61b43861bf3fb Mon Sep 17 00:00:00 2001 From: Andrew Hall Date: Tue, 3 Sep 2024 16:43:53 -0700 Subject: [PATCH 11/15] Update src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListenerBase.cs Co-authored-by: Dustin Campbell --- .../RazorWorkspaceListenerBase.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListenerBase.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListenerBase.cs index fab61aeb27b..89bb6fc0f3d 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListenerBase.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListenerBase.cs @@ -297,14 +297,14 @@ private async Task ReportUpdateProjectAsync(Stream stream, Project project, Canc var projectInfo = RazorProjectInfoHelpers.TryConvert(project, projectPath, tagHelpers); if (projectInfo is not null) { - var checkSum = projectInfo.Checksum; - if (entry.ProjectChecksum == checkSum) + var checksum = projectInfo.Checksum; + if (entry.ProjectChecksum == checksum) { _logger.LogInformation("Checksum for {projectId} did not change. Skipped sending update", project.Id); return; } - entry.ProjectChecksum = checkSum; + entry.ProjectChecksum = checksum; stream.WriteProjectInfoAction(ProjectInfoAction.Update); await stream.WriteProjectInfoAsync(projectInfo, cancellationToken).ConfigureAwait(false); From 2c0bd4d19bf37591d78a8af7f3e3b0f9da5d1c47 Mon Sep 17 00:00:00 2001 From: Andrew Hall Date: Thu, 5 Sep 2024 00:41:46 -0700 Subject: [PATCH 12/15] Remove tag helper cache --- .../CachedTagHelperResolver.DeltaResult.cs | 47 ----- .../CachedTagHelperResolver.cs | 172 ------------------ .../TagHelperResultCache.cs | 56 ------ 3 files changed, 275 deletions(-) delete mode 100644 src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/CachedTagHelperResolver.DeltaResult.cs delete mode 100644 src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/CachedTagHelperResolver.cs delete mode 100644 src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/TagHelperResultCache.cs diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/CachedTagHelperResolver.DeltaResult.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/CachedTagHelperResolver.DeltaResult.cs deleted file mode 100644 index f011a847ba4..00000000000 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/CachedTagHelperResolver.DeltaResult.cs +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT license. See License.txt in the project root for license information. - -using System.Collections.Immutable; -using Microsoft.AspNetCore.Razor.PooledObjects; -using Microsoft.AspNetCore.Razor.Utilities; - -#if DEBUG -using System.Diagnostics; -#endif - -namespace Microsoft.AspNetCore.Razor; - -internal partial class CachedTagHelperResolver -{ - public sealed record class DeltaResult( - bool IsDelta, - int ResultId, - ImmutableArray Added, - ImmutableArray Removed) - { - public ImmutableArray Apply(ImmutableArray baseChecksums) - { - if (Added.Length == 0 && Removed.Length == 0) - { - return baseChecksums; - } - - using var result = new PooledArrayBuilder(capacity: baseChecksums.Length + Added.Length - Removed.Length); - - result.AddRange(Added); - result.AddRange(Delta.Compute(Removed, baseChecksums)); - -#if DEBUG - // Ensure that there are no duplicate tag helpers in the result. - using var set = new PooledHashSet(capacity: result.Count); - - foreach (var item in result) - { - Debug.Assert(set.Add(item), $"{nameof(DeltaResult)}.{nameof(Apply)} should not contain any duplicates!"); - } -#endif - - return result.DrainToImmutable(); - } - } -} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/CachedTagHelperResolver.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/CachedTagHelperResolver.cs deleted file mode 100644 index e90130789d5..00000000000 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/CachedTagHelperResolver.cs +++ /dev/null @@ -1,172 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT license. See License.txt in the project root for license information. - -using System.Collections.Immutable; -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Razor.Language; -using Microsoft.AspNetCore.Razor.PooledObjects; -using Microsoft.AspNetCore.Razor.Telemetry; -using Microsoft.AspNetCore.Razor.Utilities; -using Microsoft.CodeAnalysis; - -namespace Microsoft.AspNetCore.Razor; - -internal partial class CachedTagHelperResolver(ITelemetryReporter telemetryReporter) -{ - private readonly CompilationTagHelperResolver _innerResolver = new(telemetryReporter); - private readonly object _gate = new(); - private int _currentResultId; - - private record Entry(int ResultId, ImmutableArray Checksums); - private readonly MemoryCache _projectResultCache = new(); - - public ImmutableArray GetValues(ProjectId projectKey, int resultId) - { - if (!TryGetCachedChecksums(projectKey, resultId, out var cachedChecksums)) - { - return []; - } - - using var builder = new PooledArrayBuilder(); - foreach (var checksum in cachedChecksums) - { - var value = TryGet(checksum); - if (value is null) - { - continue; - } - - builder.Add(value); - } - - return builder.DrainToImmutable(); - } - - public async ValueTask GetDeltaAsync(Project project, int? lastResultIdNullable, CancellationToken cancellationToken) - { - var lastResultId = lastResultIdNullable ?? -1; - - var currentChecksums = await GetCurrentChecksumsAsync(project, cancellationToken).ConfigureAwait(false); - var cacheHit = TryGetCachedChecksums(project.Id, lastResultId, out var cachedChecksums); - - if (!cacheHit) - { - cachedChecksums = []; - } - - ImmutableArray added; - ImmutableArray removed; - - if (cachedChecksums.Length < currentChecksums.Length) - { - added = Delta.Compute(cachedChecksums, currentChecksums); - - // No need to call TagHelperDelta.Compute again if we know there aren't any removed - var anyRemoved = currentChecksums.Length - cachedChecksums.Length != added.Length; - removed = anyRemoved ? Delta.Compute(currentChecksums, cachedChecksums) : ImmutableArray.Empty; - } - else - { - removed = Delta.Compute(currentChecksums, cachedChecksums); - - // No need to call TagHelperDelta.Compute again if we know there aren't any added - var anyAdded = cachedChecksums.Length - currentChecksums.Length != removed.Length; - added = anyAdded ? Delta.Compute(cachedChecksums, currentChecksums) : ImmutableArray.Empty; - } - - lock (_gate) - { - var newResultId = _currentResultId; - if (added.Length > 0 || removed.Length > 0) - { - // The result actually changed, lets generate & cache a new result - newResultId = ++_currentResultId; - SetCachedChecksums(project.Id, newResultId, currentChecksums); - } - else if (cacheHit) - { - // Re-use existing result ID if we've hit he cache so next time we get asked we hit again. - newResultId = lastResultId; - } - - return new DeltaResult(cacheHit, newResultId, added, removed); - } - } - - public bool TryGetCachedChecksums(ProjectId projectKey, int resultId, out ImmutableArray cachedChecksums) - { - if (!_projectResultCache.TryGetValue(projectKey, out var cachedResult)) - { - cachedChecksums = default; - return false; - } - else if (cachedResult.ResultId != resultId) - { - // We don't know about the result that's being requested. Fallback to uncached behavior. - cachedChecksums = default; - return false; - } - - cachedChecksums = cachedResult.Checksums; - return true; - } - - public bool TryGetId(ProjectId projectKey, out int resultId) - { - if (!_projectResultCache.TryGetValue(projectKey, out var cachedResult)) - { - resultId = -1; - return false; - } - - resultId = cachedResult.ResultId; - return true; - } - - public void SetCachedChecksums(ProjectId projectKey, int resultId, ImmutableArray checksums) - { - var cacheEntry = new Entry(resultId, checksums); - _projectResultCache.Set(projectKey, cacheEntry); - } - - protected TagHelperDescriptor? TryGet(Checksum checksum) - { - TagHelperCache.Default.TryGet(checksum, out var tagHelper); - return tagHelper; - } - - protected async ValueTask> GetCurrentChecksumsAsync(Project project, CancellationToken cancellationToken) - { - var projectPath = Path.GetDirectoryName(project.FilePath); - if (projectPath is null) - { - return []; - } - - var projectEngine = RazorProjectInfoHelpers.GetProjectEngine(project, projectPath); - if (projectEngine is null) - { - return []; - } - - var tagHelpers = await _innerResolver - .GetTagHelpersAsync(project, projectEngine, cancellationToken) - .ConfigureAwait(false); - - using var builder = new PooledArrayBuilder(capacity: tagHelpers.Length); - - // Add each tag helpers to the cache so that we can retrieve them later if needed. - var cache = TagHelperCache.Default; - - foreach (var tagHelper in tagHelpers) - { - var checksum = tagHelper.Checksum; - builder.Add(checksum); - cache.TryAdd(checksum, tagHelper); - } - - return builder.DrainToImmutable(); - } -} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/TagHelperResultCache.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/TagHelperResultCache.cs deleted file mode 100644 index 5a450bfb4c0..00000000000 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/TagHelperResultCache.cs +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT license. See License.txt in the project root for license information. - -using System.Collections.Immutable; -using Microsoft.AspNetCore.Razor.Utilities; -using Microsoft.CodeAnalysis; - -namespace Microsoft.AspNetCore.Razor; - -internal class TagHelperResultCache -{ - private record Entry(int ResultId, ImmutableArray Checksums); - - private readonly MemoryCache _projectResultCache; - - public TagHelperResultCache() - { - _projectResultCache = new MemoryCache(sizeLimit: 50); - } - - public bool TryGet(ProjectId projectKey, int resultId, out ImmutableArray cachedTagHelpers) - { - if (!_projectResultCache.TryGetValue(projectKey, out var cachedResult)) - { - cachedTagHelpers = default; - return false; - } - else if (cachedResult.ResultId != resultId) - { - // We don't know about the result that's being requested. Fallback to uncached behavior. - cachedTagHelpers = default; - return false; - } - - cachedTagHelpers = cachedResult.Checksums; - return true; - } - - public bool TryGetId(ProjectId projectKey, out int resultId) - { - if (!_projectResultCache.TryGetValue(projectKey, out var cachedResult)) - { - resultId = -1; - return false; - } - - resultId = cachedResult.ResultId; - return true; - } - - public void Set(ProjectId projectKey, int resultId, ImmutableArray tagHelpers) - { - var cacheEntry = new Entry(resultId, tagHelpers); - _projectResultCache.Set(projectKey, cacheEntry); - } -} From 80b6c89594042ef7da58ee9183c6bb44e9700c4e Mon Sep 17 00:00:00 2001 From: Andrew Hall Date: Thu, 5 Sep 2024 00:57:41 -0700 Subject: [PATCH 13/15] Don't use cached tag helper provider --- .../RazorWorkspaceListenerBase.cs | 19 +++---- .../RazorProjectInfoHelpers.cs | 2 +- .../Remote/TagHelperResultCache.cs | 55 +++++++++++++++++++ .../RemoteTagHelperDeltaProvider.cs | 2 +- 4 files changed, 64 insertions(+), 14 deletions(-) create mode 100644 src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Remote/TagHelperResultCache.cs diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListenerBase.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListenerBase.cs index 89bb6fc0f3d..29c7d4f266d 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListenerBase.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListenerBase.cs @@ -3,7 +3,6 @@ using System.Collections.Immutable; using System.Diagnostics; -using Microsoft.AspNetCore.Razor.Serialization; using Microsoft.AspNetCore.Razor.Telemetry; using Microsoft.AspNetCore.Razor.Utilities; using Microsoft.CodeAnalysis; @@ -18,10 +17,10 @@ public abstract partial class RazorWorkspaceListenerBase : IDisposable private readonly ILogger _logger; private readonly AsyncBatchingWorkQueue _workQueue; - private readonly CachedTagHelperResolver _cachedTagHelperResolver = new(NoOpTelemetryReporter.Instance); + private readonly CompilationTagHelperResolver _tagHelperResolver = new(NoOpTelemetryReporter.Instance); // Only modified in the batching work queue so no need to lock for mutation - private readonly Dictionary _projectEntryMap = new(); + private readonly Dictionary _projectChecksums = new(); // Use an immutable dictionary for ImmutableInterlocked operations. The value isn't checked, just // the existance of the key so work is only done for projects with dynamic files. @@ -288,23 +287,19 @@ private async Task ReportUpdateProjectAsync(Stream stream, Project project, Canc return; } - var entry = _projectEntryMap.GetOrAdd(project.Id, static _ => new ProjectEntry()); - - var delta = await _cachedTagHelperResolver.GetDeltaAsync(project, entry.TagHelpersResultId, cancellationToken).ConfigureAwait(false); - entry.TagHelpersResultId = delta.ResultId; - - var tagHelpers = _cachedTagHelperResolver.GetValues(project.Id, delta.ResultId); + var checksum = _projectChecksums.GetOrAdd(project.Id, static _ => null); + var projectEngine = RazorProjectInfoHelpers.GetProjectEngine(project, projectPath); + var tagHelpers = await _tagHelperResolver.GetTagHelpersAsync(project, projectEngine, cancellationToken).ConfigureAwait(false); var projectInfo = RazorProjectInfoHelpers.TryConvert(project, projectPath, tagHelpers); if (projectInfo is not null) { - var checksum = projectInfo.Checksum; - if (entry.ProjectChecksum == checksum) + if (checksum == projectInfo.Checksum) { _logger.LogInformation("Checksum for {projectId} did not change. Skipped sending update", project.Id); return; } - entry.ProjectChecksum = checksum; + _projectChecksums[project.Id] = projectInfo.Checksum; stream.WriteProjectInfoAction(ProjectInfoAction.Update); await stream.WriteProjectInfoAsync(projectInfo, cancellationToken).ConfigureAwait(false); diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/RazorProjectInfoHelpers.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/RazorProjectInfoHelpers.cs index 00a3b768a4b..ff0a4dc7ab6 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/RazorProjectInfoHelpers.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/RazorProjectInfoHelpers.cs @@ -81,7 +81,7 @@ internal static class RazorProjectInfoHelpers return ProjectWorkspaceState.Create(tagHelpers, csharpLanguageVersion); } - public static RazorProjectEngine? GetProjectEngine(Project project, string projectPath) + public static RazorProjectEngine GetProjectEngine(Project project, string projectPath) { var options = project.AnalyzerOptions.AnalyzerConfigOptionsProvider; var (configuration, rootNamespace) = ComputeRazorConfigurationOptions(options); diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Remote/TagHelperResultCache.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Remote/TagHelperResultCache.cs new file mode 100644 index 00000000000..c24e623bf4f --- /dev/null +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Remote/TagHelperResultCache.cs @@ -0,0 +1,55 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT license. See License.txt in the project root for license information. + +using System.Collections.Immutable; +using Microsoft.AspNetCore.Razor.Utilities; + +namespace Microsoft.CodeAnalysis.Razor.Remote; + +internal class TagHelperResultCache +{ + private record Entry(int ResultId, ImmutableArray Checksums); + + private readonly MemoryCache _projectResultCache; + + public TagHelperResultCache() + { + _projectResultCache = new MemoryCache(sizeLimit: 50); + } + + public bool TryGet(ProjectId projectKey, int resultId, out ImmutableArray cachedTagHelpers) + { + if (!_projectResultCache.TryGetValue(projectKey, out var cachedResult)) + { + cachedTagHelpers = default; + return false; + } + else if (cachedResult.ResultId != resultId) + { + // We don't know about the result that's being requested. Fallback to uncached behavior. + cachedTagHelpers = default; + return false; + } + + cachedTagHelpers = cachedResult.Checksums; + return true; + } + + public bool TryGetId(ProjectId projectKey, out int resultId) + { + if (!_projectResultCache.TryGetValue(projectKey, out var cachedResult)) + { + resultId = -1; + return false; + } + + resultId = cachedResult.ResultId; + return true; + } + + public void Set(ProjectId projectKey, int resultId, ImmutableArray tagHelpers) + { + var cacheEntry = new Entry(resultId, tagHelpers); + _projectResultCache.Set(projectKey, cacheEntry); + } +} diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/TagHelpers/RemoteTagHelperDeltaProvider.cs b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/TagHelpers/RemoteTagHelperDeltaProvider.cs index 64ae73dae28..849a7f21263 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/TagHelpers/RemoteTagHelperDeltaProvider.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/TagHelpers/RemoteTagHelperDeltaProvider.cs @@ -3,9 +3,9 @@ using System.Collections.Immutable; using System.Composition; -using Microsoft.AspNetCore.Razor; using Microsoft.AspNetCore.Razor.Serialization; using Microsoft.AspNetCore.Razor.Utilities; +using Microsoft.CodeAnalysis.Razor.Remote; namespace Microsoft.CodeAnalysis.Remote.Razor; From bf8283c01b89c34cc8d42d9196d0db03685b89c9 Mon Sep 17 00:00:00 2001 From: Andrew Hall Date: Thu, 5 Sep 2024 14:04:20 -0700 Subject: [PATCH 14/15] Cancellation cleanup. --- .../RazorWorkspaceListenerBase.Work.cs | 6 ++-- .../RazorWorkspaceListenerBase.cs | 29 ++++++++----------- 2 files changed, 15 insertions(+), 20 deletions(-) diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListenerBase.Work.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListenerBase.Work.cs index bc9a0e215bd..b6a612b952a 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListenerBase.Work.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListenerBase.Work.cs @@ -7,7 +7,7 @@ namespace Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace; public abstract partial class RazorWorkspaceListenerBase { - internal record Work(ProjectId ProjectId); - internal record UpdateWork(ProjectId ProjectId) : Work(ProjectId); - internal record RemovalWork(ProjectId ProjectId, string IntermediateOutputPath) : Work(ProjectId); + internal abstract record Work(ProjectId ProjectId); + internal sealed record UpdateWork(ProjectId ProjectId) : Work(ProjectId); + internal sealed record RemovalWork(ProjectId ProjectId, string IntermediateOutputPath) : Work(ProjectId); } diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListenerBase.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListenerBase.cs index 29c7d4f266d..1a1c0e4952c 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListenerBase.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListenerBase.cs @@ -28,7 +28,6 @@ public abstract partial class RazorWorkspaceListenerBase : IDisposable private Stream? _stream; private Workspace? _workspace; - private bool _disposed; private protected RazorWorkspaceListenerBase(ILogger logger) { @@ -43,22 +42,21 @@ void IDisposable.Dispose() if (_workspace is not null) { _workspace.WorkspaceChanged -= Workspace_WorkspaceChanged; + _workspace = null; } - if (_disposed) + if (_disposeTokenSource.IsCancellationRequested) { _logger.LogInformation("Disposal was called twice"); return; } - _disposed = true; _logger.LogInformation("Tearing down named pipe for pid {pid}", Process.GetCurrentProcess().Id); _disposeTokenSource.Cancel(); _disposeTokenSource.Dispose(); _stream?.Dispose(); - _stream = null; } public void NotifyDynamicFile(ProjectId projectId) @@ -94,7 +92,7 @@ private protected void EnsureInitialized(Workspace workspace, Func creat } // Early check for disposal just to reduce any work further - if (_disposed) + if (_disposeTokenSource.IsCancellationRequested) { return; } @@ -174,7 +172,7 @@ private void Workspace_WorkspaceChanged(object? sender, WorkspaceChangeEventArgs // void EnqueueUpdate(Project? project) { - if (_disposed || + if (_disposeTokenSource.IsCancellationRequested || project is not { Language: LanguageNames.CSharp @@ -220,21 +218,15 @@ void RemoveProject(Project project) /// private protected async virtual ValueTask ProcessWorkAsync(ImmutableArray work, CancellationToken cancellationToken) { - // Capture as locals here. Cancellation of the work queue still need to propogate. The cancellation - // token itself represents the work queue halting, but this will help avoid any assumptions about nullability of locals - // through the use in this function. - var stream = _stream; - var solution = _workspace?.CurrentSolution; - - cancellationToken.ThrowIfCancellationRequested(); - - // Early bail check for if we are disposed or somewhere in the middle of disposal - if (_disposed || stream is null || solution is null) + if (cancellationToken.IsCancellationRequested) { _logger.LogTrace("Skipping work due to disposal"); return; } + var stream = _stream.AssumeNotNull(); + var solution = _workspace.AssumeNotNull().CurrentSolution; + await CheckConnectionAsync(stream, cancellationToken).ConfigureAwait(false); await ProcessWorkCoreAsync(work, stream, solution, cancellationToken).ConfigureAwait(false); } @@ -245,7 +237,10 @@ private async Task ProcessWorkCoreAsync(ImmutableArray work, Stream stream { try { - cancellationToken.ThrowIfCancellationRequested(); + if (cancellationToken.IsCancellationRequested) + { + return; + } if (unit is RemovalWork removalWork) { From 8fda8d08b2877d08253fcf848fbf6491b837caeb Mon Sep 17 00:00:00 2001 From: Andrew Hall Date: Thu, 5 Sep 2024 16:17:18 -0700 Subject: [PATCH 15/15] Remove unused class --- .../RazorWorkspaceListenerBase.ProjectEntry.cs | 15 --------------- 1 file changed, 15 deletions(-) delete mode 100644 src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListenerBase.ProjectEntry.cs diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListenerBase.ProjectEntry.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListenerBase.ProjectEntry.cs deleted file mode 100644 index 53bac59b592..00000000000 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListenerBase.ProjectEntry.cs +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT license. See License.txt in the project root for license information. - -using Microsoft.AspNetCore.Razor.Utilities; - -namespace Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace; - -public abstract partial class RazorWorkspaceListenerBase -{ - internal class ProjectEntry - { - public int? TagHelpersResultId { get; set; } - public Checksum? ProjectChecksum { get; set; } - } -}