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 1f13f2d9f42..7ab3ecf2a4b 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListenerBase.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListenerBase.cs @@ -286,6 +286,7 @@ private static async Task ReportUpdateProjectAsync(Stream stream, Project projec return; } + stream.WriteProjectInfoAction(ProjectInfoAction.Update); await stream.WriteProjectInfoAsync(projectInfo, cancellationToken).ConfigureAwait(false); } diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/Utilities/StreamExtensions.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/Utilities/StreamExtensions.cs index ff5483df79f..c792e4a25b8 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/Utilities/StreamExtensions.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/Utilities/StreamExtensions.cs @@ -56,7 +56,8 @@ public static async Task ReadStringAsync(this Stream stream, Encoding? e var length = ReadSize(stream); using var _ = ArrayPool.Shared.GetPooledArray(length, out var encodedBytes); - await stream.ReadAsync(encodedBytes, 0, length, cancellationToken).ConfigureAwait(false); + + await ReadExactlyAsync(stream, encodedBytes, length, cancellationToken).ConfigureAwait(false); return encoding.GetString(encodedBytes, 0, length); } @@ -94,8 +95,6 @@ public static Task ReadProjectInfoRemovalAsync(this Stream stream, Cance public static async Task WriteProjectInfoAsync(this Stream stream, RazorProjectInfo projectInfo, CancellationToken cancellationToken) { - WriteProjectInfoAction(stream, ProjectInfoAction.Update); - var bytes = projectInfo.Serialize(); WriteSize(stream, bytes.Length); await stream.WriteAsync(bytes, 0, bytes.Length, cancellationToken).ConfigureAwait(false); @@ -106,11 +105,15 @@ public static async Task WriteProjectInfoAsync(this Stream stream, RazorProjectI var sizeToRead = ReadSize(stream); using var _ = ArrayPool.Shared.GetPooledArray(sizeToRead, out var projectInfoBytes); - await stream.ReadAsync(projectInfoBytes, 0, projectInfoBytes.Length, cancellationToken).ConfigureAwait(false); - return RazorProjectInfo.DeserializeFrom(projectInfoBytes.AsMemory()); + await ReadExactlyAsync(stream, projectInfoBytes, sizeToRead, cancellationToken).ConfigureAwait(false); + + // 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); } - private static void WriteSize(Stream stream, int length) + public static void WriteSize(this Stream stream, int length) { #if NET Span sizeBytes = stackalloc byte[4]; @@ -125,7 +128,7 @@ private static void WriteSize(Stream stream, int length) } - private unsafe static int ReadSize(Stream stream) + public unsafe static int ReadSize(this Stream stream) { #if NET Span bytes = stackalloc byte[4]; @@ -137,4 +140,24 @@ private unsafe static int ReadSize(Stream stream) return BitConverter.ToInt32(bytes, 0); #endif } + +#if NET + private static ValueTask ReadExactlyAsync(Stream stream, byte[] buffer, int length, CancellationToken cancellationToken) + => stream.ReadExactlyAsync(buffer, 0, length, cancellationToken); +#else + private static async ValueTask ReadExactlyAsync(Stream stream, byte[] buffer, int length, CancellationToken cancellationToken) + { + var bytesRead = 0; + while (bytesRead < length) + { + var read = await stream.ReadAsync(buffer, bytesRead, length - bytesRead, cancellationToken); + if (read == 0) + { + throw new EndOfStreamException(); + } + + bytesRead += read; + } + } +#endif } diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.ProjectEngineHost.Test/StreamExtensionTests.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.ProjectEngineHost.Test/StreamExtensionTests.cs new file mode 100644 index 00000000000..c0f0d50f200 --- /dev/null +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.ProjectEngineHost.Test/StreamExtensionTests.cs @@ -0,0 +1,122 @@ +// 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.Generic; +using System.Collections.Immutable; +using System.Drawing; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.ProjectSystem; +using Microsoft.AspNetCore.Razor.Serialization; +using Microsoft.AspNetCore.Razor.Utilities; +using Microsoft.CodeAnalysis; +using Xunit; + +namespace Microsoft.AspNetCore.Razor.ProjectEngineHost.Test; + +public class StreamExtensionTests +{ + [Theory] + [InlineData(0)] + [InlineData(int.MaxValue)] + [InlineData(int.MinValue)] + [InlineData(-500)] + [InlineData(500)] + public void SizeFunctions(int size) + { + using var stream = new MemoryStream(); + + stream.WriteSize(size); + stream.Position = 0; + + Assert.Equal(size, stream.ReadSize()); + } + + public static TheoryData StringFunctionData = new TheoryData + { + { "", null }, + { "hello", null }, + { "", Encoding.UTF8 }, + { "hello", Encoding.UTF8 }, + { "", Encoding.ASCII }, + { "hello", Encoding.ASCII }, + { "", Encoding.UTF32 }, + { "hello", Encoding.UTF32 }, + { "", Encoding.Unicode }, + { "hello", Encoding.Unicode }, + { "", Encoding.BigEndianUnicode }, + { "hello", Encoding.BigEndianUnicode }, + }; + + [Theory] + [MemberData(nameof(StringFunctionData))] + public async Task StringFunctions(string expected, Encoding? encoding) + { + using var stream = new MemoryStream(); + + await stream.WriteStringAsync(expected, encoding, default); + stream.Position = 0; + + var actual = await stream.ReadStringAsync(encoding, default); + Assert.Equal(expected, actual); + } + + [Fact] + public async Task SerializeProjectInfo() + { + using var stream = new MemoryStream(); + + var configuration = new RazorConfiguration( + RazorLanguageVersion.Latest, + "TestConfiguration", + ImmutableArray.Empty); + + var tagHelper = TagHelperDescriptorBuilder.Create("TypeName", "AssemblyName") + .TagMatchingRuleDescriptor(rule => rule.RequireTagName("tag-name")) + .Build(); + + var projectWorkspaceState = ProjectWorkspaceState.Create([tagHelper], CodeAnalysis.CSharp.LanguageVersion.Latest); + + var projectInfo = new RazorProjectInfo( + new ProjectKey("TestProject"), + @"C:\test\test.csproj", + configuration, + "TestNamespace", + "Test", + projectWorkspaceState, + [new DocumentSnapshotHandle(@"C:\test\document.razor", @"document.razor", FileKinds.Component)]); + + var bytesToSerialize = projectInfo.Serialize(); + + await stream.WriteProjectInfoAsync(projectInfo, default); + + // WriteProjectInfoAsync prepends the size before writing which is 4 bytes long + Assert.Equal(bytesToSerialize.Length + 4, stream.Position); + + var streamContents = stream.ToArray(); + var expectedSize = BitConverter.GetBytes(bytesToSerialize.Length); + Assert.Equal(expectedSize, streamContents.Take(4).ToArray()); + + Assert.Equal(bytesToSerialize, streamContents.Skip(4).ToArray()); + + stream.Position = 0; + var deserialized = await stream.ReadProjectInfoAsync(default); + + Assert.Equal(projectInfo, deserialized); + } + + [Theory] + [CombinatorialData] + internal void ProjectInfoActionFunctions(ProjectInfoAction infoAction) + { + using var stream = new MemoryStream(); + stream.WriteProjectInfoAction(infoAction); + + stream.Position = 0; + Assert.Equal(infoAction, stream.ReadProjectInfoAction()); + } +}