diff --git a/Directory.Build.targets b/Directory.Build.targets index 4712f2352485..7f4358b418aa 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -14,6 +14,10 @@ '$(IsMicrobenchmarksProject)' == 'true'">$(NoWarn);CS1591 + + + + true true diff --git a/eng/Versions.props b/eng/Versions.props index 3fd7fdf1fb7b..02105c572df8 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -137,7 +137,7 @@ 1.11.4 0.9.9 - 0.13.0 + 0.15.8 4.2.1 2.3.0 6.0.0 diff --git a/src/Http/WebUtilities/src/HttpResponseStreamWriter.cs b/src/Http/WebUtilities/src/HttpResponseStreamWriter.cs index 118987561a81..f36a84e4bf3b 100644 --- a/src/Http/WebUtilities/src/HttpResponseStreamWriter.cs +++ b/src/Http/WebUtilities/src/HttpResponseStreamWriter.cs @@ -22,11 +22,13 @@ public class HttpResponseStreamWriter : TextWriter private readonly ArrayPool _bytePool; private readonly ArrayPool _charPool; private readonly int _charBufferSize; + private readonly bool _isUtf8Encoding; private readonly byte[] _byteBuffer; private readonly char[] _charBuffer; private int _charBufferCount; + private int _byteBufferCount; private bool _disposed; /// @@ -69,6 +71,7 @@ public HttpResponseStreamWriter( Encoding = encoding ?? throw new ArgumentNullException(nameof(encoding)); _bytePool = bytePool ?? throw new ArgumentNullException(nameof(bytePool)); _charPool = charPool ?? throw new ArgumentNullException(nameof(charPool)); + _isUtf8Encoding = ReferenceEquals(encoding, Encoding.UTF8) || encoding is UTF8Encoding; ArgumentOutOfRangeException.ThrowIfNegativeOrZero(bufferSize); if (!_stream.CanWrite) @@ -502,12 +505,169 @@ private async Task WriteLineAsyncAwaited(string value) // We want to flush the stream when Flush/FlushAsync is explicitly // called by the user (example: from a Razor view). + /// + /// Writes pre-encoded UTF-8 bytes to the underlying stream, bypassing character encoding. + /// + /// The UTF-8 encoded bytes to write. + /// + /// This method buffers the raw bytes and flushes them to the underlying stream when the buffer is full + /// or when is called, similar to how character writes are buffered. Any pending + /// character data is encoded into the byte buffer first to maintain correct write ordering. The writer's + /// must be or a ; + /// otherwise an is thrown. + /// + /// + /// The writer's encoding is not UTF-8. + /// + public void WriteUtf8(ReadOnlySpan utf8Value) + { + ThrowIfDisposed(); + ThrowIfNotUtf8Encoding(); + + if (utf8Value.IsEmpty) + { + return; + } + + // Encode pending chars into byte buffer to maintain write ordering + if (_charBufferCount > 0) + { + FlushInternal(flushEncoder: true); + } + + // Buffer the UTF-8 bytes + BufferUtf8Bytes(utf8Value); + } + + /// + /// Asynchronously writes pre-encoded UTF-8 bytes to the underlying stream, bypassing character encoding. + /// + /// The UTF-8 encoded bytes to write. + /// The token to monitor for cancellation requests. + /// A task that represents the asynchronous write operation. + /// + /// This method buffers the raw bytes and flushes them to the underlying stream when the buffer is full + /// or when is called, similar to how character writes are buffered. Any pending + /// character data is encoded into the byte buffer first to maintain correct write ordering. The writer's + /// must be or a ; + /// otherwise an is thrown. + /// + /// + /// The writer's encoding is not UTF-8. + /// + public Task WriteUtf8Async(ReadOnlyMemory utf8Value, CancellationToken cancellationToken = default) + { + if (_disposed) + { + return GetObjectDisposedTask(); + } + + ThrowIfNotUtf8Encoding(); + + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + + if (utf8Value.IsEmpty) + { + return Task.CompletedTask; + } + + // Fast path: no pending chars, bytes fit in remaining buffer space. + // Just memcpy and return — no async state machine, no stream I/O. + if (_charBufferCount == 0 && _byteBufferCount + utf8Value.Length <= _byteBuffer.Length) + { + utf8Value.Span.CopyTo(_byteBuffer.AsSpan(_byteBufferCount)); + _byteBufferCount += utf8Value.Length; + return Task.CompletedTask; + } + + // Second fast path: pending chars + UTF-8 bytes all fit in byte buffer. + // Encode chars synchronously, then memcpy — still no async, no stream I/O. + if (_charBufferCount > 0) + { + var maxBytesForChars = Encoding.GetMaxByteCount(_charBufferCount); + if (_byteBufferCount + maxBytesForChars + utf8Value.Length <= _byteBuffer.Length) + { + var encodedCount = _encoder.GetBytes( + _charBuffer, + 0, + _charBufferCount, + _byteBuffer, + _byteBufferCount, + flush: true); + + _charBufferCount = 0; + _byteBufferCount += encodedCount; + + utf8Value.Span.CopyTo(_byteBuffer.AsSpan(_byteBufferCount)); + _byteBufferCount += utf8Value.Length; + return Task.CompletedTask; + } + } + + return WriteUtf8AsyncCore(utf8Value, cancellationToken); + } + + private Task WriteUtf8AsyncCore(ReadOnlyMemory utf8Value, CancellationToken cancellationToken) + { + // Encode pending chars into byte buffer (may flush byte buffer to stream if needed) + if (_charBufferCount > 0) + { + var flushTask = FlushInternalAsync(flushEncoder: true); + if (!flushTask.IsCompletedSuccessfully) + { + return WriteUtf8AsyncCoreAwaited(flushTask, utf8Value, cancellationToken); + } + } + + // Flush byte buffer if the new bytes don't fit + if (_byteBufferCount > 0 && _byteBufferCount + utf8Value.Length > _byteBuffer.Length) + { + var pendingCount = _byteBufferCount; + _byteBufferCount = 0; + var writeTask = WriteToStreamAsync(_byteBuffer.AsMemory(0, pendingCount), cancellationToken); + if (!writeTask.IsCompletedSuccessfully) + { + return BufferUtf8BytesAfterWriteAsync(writeTask, utf8Value, cancellationToken); + } + } + + // Buffer the bytes, or write directly if larger than the entire buffer + if (utf8Value.Length <= _byteBuffer.Length - _byteBufferCount) + { + utf8Value.Span.CopyTo(_byteBuffer.AsSpan(_byteBufferCount)); + _byteBufferCount += utf8Value.Length; + } + else + { + // Large payload: write directly, bypassing buffer + return WriteToStreamAsync(utf8Value, cancellationToken); + } + + return Task.CompletedTask; + } + + private async Task WriteUtf8AsyncCoreAwaited(Task flushTask, ReadOnlyMemory utf8Value, CancellationToken cancellationToken) + { + await flushTask; + await WriteUtf8AsyncCore(utf8Value, cancellationToken); + } + + private async Task BufferUtf8BytesAfterWriteAsync(Task writeTask, ReadOnlyMemory utf8Value, CancellationToken cancellationToken) + { + await writeTask; + await WriteUtf8AsyncCore(utf8Value, cancellationToken); + } + /// public override void Flush() { ThrowIfDisposed(); FlushInternal(flushEncoder: true); + FlushByteBuffer(); } /// @@ -518,7 +678,69 @@ public override Task FlushAsync() return GetObjectDisposedTask(); } - return FlushInternalAsync(flushEncoder: true); + return FlushAllAsync(); + } + + private async Task FlushAllAsync() + { + await FlushInternalAsync(flushEncoder: true); + await FlushByteBufferAsync(); + } + + private void FlushByteBuffer() + { + if (_byteBufferCount > 0) + { + var count = _byteBufferCount; + _byteBufferCount = 0; + _stream.Write(_byteBuffer, 0, count); + } + } + + private Task FlushByteBufferAsync() + { + if (_byteBufferCount == 0) + { + return Task.CompletedTask; + } + + return FlushByteBufferAsyncCore(); + } + + private Task FlushByteBufferAsyncCore() + { + var count = _byteBufferCount; + _byteBufferCount = 0; + return WriteToStreamAsync(_byteBuffer.AsMemory(0, count)); + } + + private void BufferUtf8Bytes(ReadOnlySpan utf8Value) + { + while (utf8Value.Length > 0) + { + var available = _byteBuffer.Length - _byteBufferCount; + + if (available == 0) + { + // Buffer full, flush to stream (reset count before write for exception safety) + var count = _byteBufferCount; + _byteBufferCount = 0; + _stream.Write(_byteBuffer, 0, count); + available = _byteBuffer.Length; + } + + // Large payload: bypass buffer entirely + if (_byteBufferCount == 0 && utf8Value.Length >= _byteBuffer.Length) + { + _stream.Write(utf8Value); + return; + } + + var toCopy = Math.Min(utf8Value.Length, available); + utf8Value[..toCopy].CopyTo(_byteBuffer.AsSpan(_byteBufferCount)); + _byteBufferCount += toCopy; + utf8Value = utf8Value.Slice(toCopy); + } } /// @@ -530,6 +752,7 @@ protected override void Dispose(bool disposing) try { FlushInternal(flushEncoder: true); + FlushByteBuffer(); } finally { @@ -550,6 +773,7 @@ public override async ValueTask DisposeAsync() try { await FlushInternalAsync(flushEncoder: true); + await FlushByteBufferAsync(); } finally { @@ -562,7 +786,8 @@ public override async ValueTask DisposeAsync() } // Note: our FlushInternal method does NOT flush the underlying stream. This would result in - // chunking. + // chunking. It encodes pending chars into the byte buffer at the current _byteBufferCount + // offset, flushing the byte buffer to the stream first if needed to make room. private void FlushInternal(bool flushEncoder) { if (_charBufferCount == 0) @@ -570,45 +795,104 @@ private void FlushInternal(bool flushEncoder) return; } + // Check if the encoded chars will fit in the remaining byte buffer space + var maxBytesNeeded = Encoding.GetMaxByteCount(_charBufferCount); + if (_byteBufferCount + maxBytesNeeded > _byteBuffer.Length) + { + // Flush pending bytes to make room + if (_byteBufferCount > 0) + { + var pendingCount = _byteBufferCount; + _byteBufferCount = 0; + _stream.Write(_byteBuffer, 0, pendingCount); + } + } + var count = _encoder.GetBytes( _charBuffer, 0, _charBufferCount, _byteBuffer, - 0, + _byteBufferCount, flush: flushEncoder); _charBufferCount = 0; + _byteBufferCount += count; + } - if (count > 0) + // Note: our FlushInternalAsync method does NOT flush the underlying stream. This would result in + // chunking. It encodes pending chars into the byte buffer, with a sync fast path when the + // encoded chars fit in the remaining byte buffer space (avoiding async state machine creation). + private Task FlushInternalAsync(bool flushEncoder) + { + if (_charBufferCount == 0) { - _stream.Write(_byteBuffer, 0, count); + return Task.CompletedTask; } + + // Fast path: encoded chars fit in remaining byte buffer space — pure sync + var maxBytesNeeded = Encoding.GetMaxByteCount(_charBufferCount); + if (_byteBufferCount + maxBytesNeeded <= _byteBuffer.Length) + { + var count = _encoder.GetBytes( + _charBuffer, + 0, + _charBufferCount, + _byteBuffer, + _byteBufferCount, + flush: flushEncoder); + + _charBufferCount = 0; + _byteBufferCount += count; + return Task.CompletedTask; + } + + return FlushInternalAsyncCore(flushEncoder); } - // Note: our FlushInternalAsync method does NOT flush the underlying stream. This would result in - // chunking. - private async Task FlushInternalAsync(bool flushEncoder) + private Task FlushInternalAsyncCore(bool flushEncoder) { - if (_charBufferCount == 0) + // Flush pending bytes to make room for encoded chars + if (_byteBufferCount > 0) { - return; + var pendingCount = _byteBufferCount; + _byteBufferCount = 0; + var writeTask = WriteToStreamAsync(_byteBuffer.AsMemory(0, pendingCount)); + if (!writeTask.IsCompletedSuccessfully) + { + return FlushInternalAsyncCoreAwaited(writeTask, flushEncoder); + } } + EncodeCharBuffer(flushEncoder); + + return Task.CompletedTask; + } + + private async Task FlushInternalAsyncCoreAwaited(Task writeTask, bool flushEncoder) + { + await writeTask; + EncodeCharBuffer(flushEncoder); + } + + private void EncodeCharBuffer(bool flushEncoder) + { var count = _encoder.GetBytes( _charBuffer, 0, _charBufferCount, _byteBuffer, - 0, + _byteBufferCount, flush: flushEncoder); _charBufferCount = 0; + _byteBufferCount += count; + } - if (count > 0) - { - await _stream.WriteAsync(_byteBuffer.AsMemory(0, count)); - } + private Task WriteToStreamAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + var writeTask = _stream.WriteAsync(buffer, cancellationToken); + return writeTask.IsCompletedSuccessfully ? Task.CompletedTask : writeTask.AsTask(); } private void CopyToCharBuffer(string value) @@ -678,4 +962,12 @@ private void ThrowIfDisposed() { ObjectDisposedException.ThrowIf(_disposed, this); } + + private void ThrowIfNotUtf8Encoding() + { + if (!_isUtf8Encoding) + { + throw new InvalidOperationException($"WriteUtf8 requires a UTF-8 encoding, but the writer's encoding is '{Encoding.WebName}'."); + } + } } diff --git a/src/Http/WebUtilities/src/PublicAPI.Unshipped.txt b/src/Http/WebUtilities/src/PublicAPI.Unshipped.txt index 7dc5c58110bf..91389b135cf0 100644 --- a/src/Http/WebUtilities/src/PublicAPI.Unshipped.txt +++ b/src/Http/WebUtilities/src/PublicAPI.Unshipped.txt @@ -1 +1,3 @@ #nullable enable +Microsoft.AspNetCore.WebUtilities.HttpResponseStreamWriter.WriteUtf8(System.ReadOnlySpan utf8Value) -> void +Microsoft.AspNetCore.WebUtilities.HttpResponseStreamWriter.WriteUtf8Async(System.ReadOnlyMemory utf8Value, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! diff --git a/src/Http/WebUtilities/test/HttpResponseStreamWriterTest.cs b/src/Http/WebUtilities/test/HttpResponseStreamWriterTest.cs index 3d01a6cd6b43..43d060756581 100644 --- a/src/Http/WebUtilities/test/HttpResponseStreamWriterTest.cs +++ b/src/Http/WebUtilities/test/HttpResponseStreamWriterTest.cs @@ -168,14 +168,14 @@ public async Task FlushesBuffer_ButNotStream_OnFlushAsync(int byteLength) var writer = new HttpResponseStreamWriter(stream, Encoding.UTF8); await writer.WriteAsync(new string('a', byteLength)); - var expectedWriteCount = Math.Ceiling((double)byteLength / HttpResponseStreamWriter.DefaultBufferSize); - // Act await writer.FlushAsync(); // Assert Assert.Equal(0, stream.FlushAsyncCallCount); - Assert.Equal(expectedWriteCount, stream.WriteAsyncCallCount); + // Byte buffering may coalesce multiple char-encoded batches into fewer stream writes, + // but all data must reach the stream and at least one write must occur. + Assert.True(stream.WriteAsyncCallCount >= 1); Assert.Equal(byteLength, stream.Length); } @@ -916,4 +916,141 @@ public static IEnumerable HttpResponseDisposeDataAsync() await httpResponseStreamWriter.FlushAsync(); })}; } + + [Fact] + public void WriteUtf8_WritesBytesDirectlyToStream() + { + var stream = new MemoryStream(); + var writer = new HttpResponseStreamWriter(stream, Encoding.UTF8); + var utf8Bytes = "

Hello World

"u8; + + writer.WriteUtf8(utf8Bytes); + writer.Flush(); + + Assert.Equal(utf8Bytes.ToArray(), stream.ToArray()); + } + + [Fact] + public async Task WriteUtf8Async_WritesBytesDirectlyToStream() + { + var stream = new MemoryStream(); + var writer = new HttpResponseStreamWriter(stream, Encoding.UTF8); + var utf8Bytes = "

Hello World

"u8.ToArray(); + + await writer.WriteUtf8Async(utf8Bytes); + await writer.FlushAsync(); + + Assert.Equal(utf8Bytes, stream.ToArray()); + } + + [Fact] + public void WriteUtf8_FlushesCharBufferFirst_MaintainsOrdering() + { + var stream = new MemoryStream(); + var writer = new HttpResponseStreamWriter(stream, Encoding.UTF8); + + writer.Write("Hello "); + writer.WriteUtf8("World"u8); + writer.Flush(); + + var output = Encoding.UTF8.GetString(stream.ToArray()); + Assert.Equal("Hello World", output); + } + + [Fact] + public async Task WriteUtf8Async_FlushesCharBufferFirst_MaintainsOrdering() + { + var stream = new MemoryStream(); + var writer = new HttpResponseStreamWriter(stream, Encoding.UTF8); + + await writer.WriteAsync("Hello "); + await writer.WriteUtf8Async("World"u8.ToArray()); + await writer.FlushAsync(); + + var output = Encoding.UTF8.GetString(stream.ToArray()); + Assert.Equal("Hello World", output); + } + + [Fact] + public void WriteUtf8_MixedCharsAndBytes_InterleavedCorrectly() + { + var stream = new MemoryStream(); + var writer = new HttpResponseStreamWriter(stream, Encoding.UTF8); + + writer.Write(""); + writer.WriteUtf8(""u8); + writer.Write("Test"); + writer.WriteUtf8(""u8); + writer.Write(""); + writer.Flush(); + + var output = Encoding.UTF8.GetString(stream.ToArray()); + Assert.Equal("Test", output); + } + + [Fact] + public void WriteUtf8_WithEmptySpan_DoesNothing() + { + var stream = new MemoryStream(); + var writer = new HttpResponseStreamWriter(stream, Encoding.UTF8); + + writer.WriteUtf8(ReadOnlySpan.Empty); + writer.Flush(); + + Assert.Empty(stream.ToArray()); + } + + [Fact] + public void WriteUtf8_ThrowsForNonUtf8Encoding() + { + var stream = new MemoryStream(); + var writer = new HttpResponseStreamWriter(stream, Encoding.Unicode); + + Assert.Throws(() => writer.WriteUtf8("

test

"u8)); + } + + [Fact] + public async Task WriteUtf8Async_ThrowsForNonUtf8Encoding() + { + var stream = new MemoryStream(); + var writer = new HttpResponseStreamWriter(stream, Encoding.Unicode); + + await Assert.ThrowsAsync( + () => writer.WriteUtf8Async("

test

"u8.ToArray())); + } + + [Fact] + public void WriteUtf8_ThrowsWhenDisposed() + { + var stream = new MemoryStream(); + var writer = new HttpResponseStreamWriter(stream, Encoding.UTF8); + writer.Dispose(); + + Assert.Throws(() => writer.WriteUtf8("

test

"u8)); + } + + [Fact] + public async Task WriteUtf8Async_ThrowsWhenDisposed() + { + var stream = new MemoryStream(); + var writer = new HttpResponseStreamWriter(stream, Encoding.UTF8); + writer.Dispose(); + + await Assert.ThrowsAsync( + () => writer.WriteUtf8Async("

test

"u8.ToArray())); + } + + [Fact] + public void WriteUtf8_BytesNotReEncoded() + { + // Verify the bytes go to the stream byte-for-byte without re-encoding + var stream = new MemoryStream(); + var writer = new HttpResponseStreamWriter(stream, Encoding.UTF8); + var originalBytes = "Héllo Wörld — 日本語"u8.ToArray(); + + writer.WriteUtf8(originalBytes); + writer.Flush(); + + Assert.Equal(originalBytes, stream.ToArray()); + } } diff --git a/src/Mvc/Mvc.Razor/src/PublicAPI.Unshipped.txt b/src/Mvc/Mvc.Razor/src/PublicAPI.Unshipped.txt index 7dc5c58110bf..a3f8c178124d 100644 --- a/src/Mvc/Mvc.Razor/src/PublicAPI.Unshipped.txt +++ b/src/Mvc/Mvc.Razor/src/PublicAPI.Unshipped.txt @@ -1 +1,2 @@ #nullable enable +virtual Microsoft.AspNetCore.Mvc.Razor.RazorPageBase.WriteLiteral(System.ReadOnlyMemory utf8Value) -> void diff --git a/src/Mvc/Mvc.Razor/src/RazorPageBase.cs b/src/Mvc/Mvc.Razor/src/RazorPageBase.cs index 44954da0ae6e..5a1ecba97044 100644 --- a/src/Mvc/Mvc.Razor/src/RazorPageBase.cs +++ b/src/Mvc/Mvc.Razor/src/RazorPageBase.cs @@ -4,6 +4,7 @@ using System.ComponentModel; using System.Diagnostics; using System.Security.Claims; +using System.Text; using System.Text.Encodings.Web; using Microsoft.AspNetCore.Antiforgery; using Microsoft.AspNetCore.Html; @@ -472,6 +473,38 @@ public virtual void WriteLiteral(string? value) } } + /// + /// Writes the specified UTF-8 encoded without HTML encoding to . + /// + /// The UTF-8 encoded HTML literal bytes to write. + /// + /// This overload is used by the Razor compiler when emitting HTML literals as UTF-8 byte arrays + /// (C# "..."u8 literals) instead of regular string literals, enabling more efficient + /// end-to-end UTF-8 output when the response encoding is UTF-8. + /// + public virtual void WriteLiteral(ReadOnlyMemory utf8Value) + { + if (utf8Value.IsEmpty) + { + return; + } + + var writer = Output; + + if (writer is ViewBufferTextWriter { IsUtf8Encoding: true } viewBufferWriter) + { + // When the output encoding is UTF-8 and we're writing to a ViewBuffer, + // preserve the raw UTF-8 bytes through the buffer pipeline. + viewBufferWriter.Buffer.AppendHtml(utf8Value); + } + else + { + // For non-UTF-8 encodings or non-buffered writers, decode to string + // and use the existing string-based write path. + writer.Write(Encoding.UTF8.GetString(utf8Value.Span)); + } + } + /// /// Begins writing out an attribute. /// diff --git a/src/Mvc/Mvc.Razor/test/RazorPageTest.cs b/src/Mvc/Mvc.Razor/test/RazorPageTest.cs index cb0e02c301ca..c9dfb5fab696 100644 --- a/src/Mvc/Mvc.Razor/test/RazorPageTest.cs +++ b/src/Mvc/Mvc.Razor/test/RazorPageTest.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Globalization; using System.Text; using System.Text.Encodings.Web; using Microsoft.AspNetCore.Html; @@ -1336,6 +1337,85 @@ public void WriteLiteral_BuffersResultToPushedWriter() Assert.Equal("This should be buffered", bufferWriter.ToString()); } + [Fact] + public void WriteLiteral_Utf8_EmptyValue_DoesNothing() + { + var page = CreatePage(p => { }); + var buffer = new ViewBuffer(new TestViewBufferScope(), string.Empty, pageSize: 32); + var writer = new ViewBufferTextWriter(buffer, Encoding.UTF8); + page.ViewContext.Writer = writer; + + page.WriteLiteral(ReadOnlyMemory.Empty); + + Assert.Equal(0, buffer.Count); + } + + [Fact] + public void WriteLiteral_Utf8_WithUtf8Encoding_BuffersAsUtf8Value() + { + var page = CreatePage(p => { }); + var buffer = new ViewBuffer(new TestViewBufferScope(), string.Empty, pageSize: 32); + var writer = new ViewBufferTextWriter(buffer, Encoding.UTF8); + page.ViewContext.Writer = writer; + + var utf8Bytes = Encoding.UTF8.GetBytes("

Hello World

"); + + page.WriteLiteral(new ReadOnlyMemory(utf8Bytes)); + + Assert.Equal(1, buffer.Count); + var page0 = buffer[0]; + Assert.Equal(1, page0.Count); + Assert.True(page0.Buffer[0].IsUtf8Value); + Assert.True(utf8Bytes.AsSpan().SequenceEqual(page0.Buffer[0].Utf8Value.Span)); + } + + [Fact] + public void WriteLiteral_Utf8_WithUtf8Encoding_ProducesCorrectOutput() + { + var page = CreatePage(p => { }); + var buffer = new ViewBuffer(new TestViewBufferScope(), string.Empty, pageSize: 32); + var writer = new ViewBufferTextWriter(buffer, Encoding.UTF8); + page.ViewContext.Writer = writer; + + page.WriteLiteral(new ReadOnlyMemory(Encoding.UTF8.GetBytes("

Hello

"))); + + Assert.Equal("

Hello

", HtmlContentUtilities.HtmlContentToString(buffer, HtmlEncoder.Default)); + } + + [Fact] + public void WriteLiteral_Utf8_WithNonUtf8Encoding_WritesAsString() + { + var page = CreatePage(p => { }); + var defaultWriter = new StringWriter(new StringBuilder(), CultureInfo.InvariantCulture); + page.ViewContext.Writer = defaultWriter; + + var utf8Bytes = Encoding.UTF8.GetBytes("

Hello

"); + + page.WriteLiteral(new ReadOnlyMemory(utf8Bytes)); + + // StringWriter uses Unicode encoding, not UTF-8, so WriteLiteral should + // decode to string and write directly + Assert.Equal("

Hello

", defaultWriter.ToString()); + } + + [Fact] + public void WriteLiteral_Utf8_MixedWithStringLiterals_ProducesCorrectOutput() + { + var page = CreatePage(p => { }); + var buffer = new ViewBuffer(new TestViewBufferScope(), string.Empty, pageSize: 32); + var writer = new ViewBufferTextWriter(buffer, Encoding.UTF8); + page.ViewContext.Writer = writer; + + page.WriteLiteral(""); + page.WriteLiteral(new ReadOnlyMemory(Encoding.UTF8.GetBytes(""))); + page.WriteLiteral("

Title

"); + page.WriteLiteral(new ReadOnlyMemory(Encoding.UTF8.GetBytes(""))); + page.WriteLiteral(""); + + Assert.Equal("

Title

", + HtmlContentUtilities.HtmlContentToString(buffer, HtmlEncoder.Default)); + } + [Fact] public void Write_StringValue_UsesSpecifiedWriter_EncodesValue() { diff --git a/src/Mvc/Mvc.Razor/test/Utf8GeneratedViewTest.cs b/src/Mvc/Mvc.Razor/test/Utf8GeneratedViewTest.cs new file mode 100644 index 000000000000..383668d7ef2c --- /dev/null +++ b/src/Mvc/Mvc.Razor/test/Utf8GeneratedViewTest.cs @@ -0,0 +1,316 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Html; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.Mvc.ViewEngines; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.AspNetCore.Mvc.ViewFeatures.Buffers; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.WebEncoders.Testing; +using Moq; + +namespace Microsoft.AspNetCore.Mvc.Razor; + +/// +/// Tests that simulate Razor-compiled views using WriteLiteral(ReadOnlyMemory<byte>) +/// with UTF-8 string literals. These represent what the Razor compiler will emit once +/// it supports the "..."u8 literal syntax for HTML content blocks. +/// +public class Utf8GeneratedViewTest +{ + [Fact] + public async Task SimpleView_WritesUtf8LiteralsCorrectly() + { + var (page, buffer) = CreatePageWithBuffer(); + + await page.ExecuteAsync(); + + var output = GetBufferContent(buffer); + Assert.Equal( + "\r\nProducts\r\n\r\n

Product List

\r\n\r\n", + output); + } + + [Fact] + public async Task ViewWithDynamicContent_MixesUtf8LiteralsAndEncodedValues() + { + var (page, buffer) = CreatePageWithBuffer(); + page.ProductName = "Widget "; + page.Price = 29.99m; + + await page.ExecuteAsync(); + + var output = GetBufferContent(buffer); + Assert.Equal( + "
\r\n

HtmlEncode[[Widget ]]

\r\n HtmlEncode[[29.99]]\r\n
", + output); + } + + [Fact] + public async Task ViewWithLoop_WritesUtf8LiteralsInLoop() + { + var (page, buffer) = CreatePageWithBuffer(); + page.Items = ["Alpha", "Beta", "Gamma"]; + + await page.ExecuteAsync(); + + var output = GetBufferContent(buffer); + Assert.Equal( + "
    \r\n
  • HtmlEncode[[Alpha]]
  • \r\n
  • HtmlEncode[[Beta]]
  • \r\n
  • HtmlEncode[[Gamma]]
  • \r\n
", + output); + } + + [Fact] + public async Task ViewWithConditional_WritesCorrectBranch() + { + var (page, buffer) = CreatePageWithBuffer(); + page.IsLoggedIn = true; + page.UserName = "Alice"; + + await page.ExecuteAsync(); + + var output = GetBufferContent(buffer); + Assert.Equal( + "", + output); + } + + [Fact] + public async Task ViewWithConditional_WritesElseBranch() + { + var (page, buffer) = CreatePageWithBuffer(); + page.IsLoggedIn = false; + + await page.ExecuteAsync(); + + var output = GetBufferContent(buffer); + Assert.Equal( + "", + output); + } + + [Fact] + public async Task ViewWithMultiByteCharacters_PreservesUtf8Content() + { + var (page, buffer) = CreatePageWithBuffer(); + + await page.ExecuteAsync(); + + var output = GetBufferContent(buffer); + Assert.Equal( + "

Héllo Wörld — 日本語テスト

", + output); + } + + private static (TPage page, ViewBuffer buffer) CreatePageWithBuffer() where TPage : RazorPage, new() + { + var bufferScope = new TestViewBufferScope(); + var buffer = new ViewBuffer(bufferScope, "test-view", pageSize: 32); + var writer = new ViewBufferTextWriter(buffer, Encoding.UTF8); + + var httpContext = new DefaultHttpContext(); + httpContext.RequestServices = new ServiceCollection() + .AddSingleton(bufferScope) + .BuildServiceProvider(); + + var viewContext = new ViewContext( + new ActionContext(httpContext, new RouteData(), new ActionDescriptor()), + Mock.Of(), + new ViewDataDictionary(new EmptyModelMetadataProvider(), new ModelStateDictionary()), + Mock.Of(), + writer, + new HtmlHelperOptions()); + + var page = new TPage(); + page.ViewContext = viewContext; + page.HtmlEncoder = new HtmlTestEncoder(); + + return (page, buffer); + } + + private static string GetBufferContent(ViewBuffer buffer) + { + using var writer = new StringWriter(); + buffer.WriteTo(writer, new HtmlTestEncoder()); + return writer.ToString(); + } + + #region Example Razor-compiled views using UTF-8 literals + + /// + /// Simulates a simple Razor view that only contains HTML literals (no dynamic content). + /// Equivalent to: + /// + /// <html> + /// <head><title>Products</title></head> + /// <body> + /// <h1>Product List</h1> + /// </body> + /// </html> + /// + /// + [CompilerGenerated] + internal sealed class SimpleProductView : RazorPage + { + private static class __Literals + { + public static readonly byte[] Literal_0 = "\r\nProducts\r\n\r\n

Product List

\r\n\r\n"u8.ToArray(); + } + + public override Task ExecuteAsync() + { + WriteLiteral(new ReadOnlyMemory(__Literals.Literal_0)); + return Task.CompletedTask; + } + } + + /// + /// Simulates a Razor view with dynamic content mixed with UTF-8 HTML literals. + /// Equivalent to: + /// + /// <div class="product"> + /// <h2>@ProductName</h2> + /// <span class="price">@Price</span> + /// </div> + /// + /// + [CompilerGenerated] + internal sealed class ProductDetailView : RazorPage + { + public string ProductName { get; set; } = string.Empty; + public decimal Price { get; set; } + + private static class __Literals + { + public static readonly byte[] Literal_0 = "
\r\n

"u8.ToArray(); + public static readonly byte[] Literal_1 = "

\r\n "u8.ToArray(); + public static readonly byte[] Literal_2 = "\r\n
"u8.ToArray(); + } + + public override Task ExecuteAsync() + { + WriteLiteral(new ReadOnlyMemory(__Literals.Literal_0)); + Write(ProductName); + WriteLiteral(new ReadOnlyMemory(__Literals.Literal_1)); + Write(Price); + WriteLiteral(new ReadOnlyMemory(__Literals.Literal_2)); + return Task.CompletedTask; + } + } + + /// + /// Simulates a Razor view with a loop over dynamic content. + /// Equivalent to: + /// + /// <ul> + /// @foreach (var item in Items) + /// { + /// <li>@item</li> + /// } + /// </ul> + /// + /// + [CompilerGenerated] + internal sealed class ProductListView : RazorPage + { + public IReadOnlyList Items { get; set; } = []; + + private static class __Literals + { + public static readonly byte[] Literal_0 = "
    "u8.ToArray(); + public static readonly byte[] Literal_1 = "\r\n
  • "u8.ToArray(); + public static readonly byte[] Literal_2 = "
  • "u8.ToArray(); + public static readonly byte[] Literal_3 = "\r\n
"u8.ToArray(); + } + + public override Task ExecuteAsync() + { + WriteLiteral(new ReadOnlyMemory(__Literals.Literal_0)); + foreach (var item in Items) + { + WriteLiteral(new ReadOnlyMemory(__Literals.Literal_1)); + Write(item); + WriteLiteral(new ReadOnlyMemory(__Literals.Literal_2)); + } + WriteLiteral(new ReadOnlyMemory(__Literals.Literal_3)); + return Task.CompletedTask; + } + } + + /// + /// Simulates a Razor view with conditional rendering. + /// Equivalent to: + /// + /// <nav> + /// @if (IsLoggedIn) + /// { + /// <span>Welcome, @UserName!</span> + /// } + /// else + /// { + /// <a href="/login">Sign In</a> + /// } + /// </nav> + /// + /// + [CompilerGenerated] + internal sealed class ConditionalView : RazorPage + { + public bool IsLoggedIn { get; set; } + public string UserName { get; set; } = string.Empty; + + private static class __Literals + { + public static readonly byte[] Literal_0 = ""u8.ToArray(); + } + + public override Task ExecuteAsync() + { + WriteLiteral(new ReadOnlyMemory(__Literals.Literal_0)); + if (IsLoggedIn) + { + WriteLiteral(new ReadOnlyMemory(__Literals.Literal_1)); + Write(UserName); + WriteLiteral(new ReadOnlyMemory(__Literals.Literal_2)); + } + else + { + WriteLiteral(new ReadOnlyMemory(__Literals.Literal_3)); + } + WriteLiteral(new ReadOnlyMemory(__Literals.Literal_4)); + return Task.CompletedTask; + } + } + + /// + /// Simulates a Razor view with multi-byte UTF-8 characters to verify correct encoding handling. + /// + [CompilerGenerated] + internal sealed class InternationalView : RazorPage + { + private static class __Literals + { + public static readonly byte[] Literal_0 = "

Héllo Wörld — 日本語テスト

"u8.ToArray(); + } + + public override Task ExecuteAsync() + { + WriteLiteral(new ReadOnlyMemory(__Literals.Literal_0)); + return Task.CompletedTask; + } + } + + #endregion +} diff --git a/src/Mvc/Mvc.ViewFeatures/src/Buffers/PagedBufferedTextWriter.cs b/src/Mvc/Mvc.ViewFeatures/src/Buffers/PagedBufferedTextWriter.cs index 68fed2edf3b8..583a217198b5 100644 --- a/src/Mvc/Mvc.ViewFeatures/src/Buffers/PagedBufferedTextWriter.cs +++ b/src/Mvc/Mvc.ViewFeatures/src/Buffers/PagedBufferedTextWriter.cs @@ -5,6 +5,7 @@ using System.Diagnostics; using System.Runtime.CompilerServices; using System.Text; +using Microsoft.AspNetCore.WebUtilities; namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Buffers; @@ -144,6 +145,97 @@ private async Task WriteAsyncAwaited(Task flushTask, string value) await _inner.WriteAsync(value); } + /// + /// Writes pre-encoded UTF-8 bytes directly through to the inner writer, bypassing char buffering. + /// + internal void WriteUtf8(ReadOnlySpan utf8Value) + { + if (utf8Value.IsEmpty) + { + return; + } + + // Flush any buffered chars to maintain write ordering + FlushSyncInternal(); + + // Forward to inner writer if it supports direct UTF-8 + if (_inner is HttpResponseStreamWriter responseWriter) + { + responseWriter.WriteUtf8(utf8Value); + } + else + { + // Fallback for writers that don't support direct UTF-8 + _inner.Write(Encoding.UTF8.GetString(utf8Value)); + } + } + + /// + /// Asynchronously writes pre-encoded UTF-8 bytes directly through to the inner writer. + /// + internal Task WriteUtf8Async(ReadOnlyMemory utf8Value) + { + if (utf8Value.IsEmpty) + { + return Task.CompletedTask; + } + + // Flush buffered chars to maintain write ordering + var flushTask = FlushAsyncCore(); + if (flushTask.IsCompletedSuccessfully) + { + return ForwardUtf8ToInnerAsync(utf8Value); + } + + return WriteUtf8AsyncAwaited(flushTask, utf8Value); + } + + private Task ForwardUtf8ToInnerAsync(ReadOnlyMemory utf8Value) + { + if (_inner is HttpResponseStreamWriter responseWriter) + { + return responseWriter.WriteUtf8Async(utf8Value); + } + + // Fallback for writers that don't support direct UTF-8 + return _inner.WriteAsync(Encoding.UTF8.GetString(utf8Value.Span)); + } + + private async Task WriteUtf8AsyncAwaited(Task flushTask, ReadOnlyMemory utf8Value) + { + await flushTask; + await ForwardUtf8ToInnerAsync(utf8Value); + } + + // Synchronous flush of buffered char pages to the inner writer. + // Unlike FlushAsyncAwaited, this writes synchronously and is only used + // by WriteUtf8 to ensure char data is flushed before raw bytes. + private void FlushSyncInternal() + { + var length = _charBuffer.Length; + if (length == 0) + { + return; + } + + var pages = _charBuffer.Pages; + var count = pages.Count; + for (var i = 0; i < count; i++) + { + var page = pages[i]; + var pageLength = Math.Min(length, page.Length); + if (pageLength != 0) + { + _inner.Write(page, index: 0, count: pageLength); + } + + length -= pageLength; + } + + Debug.Assert(length == 0); + _charBuffer.Clear(); + } + protected override void Dispose(bool disposing) { base.Dispose(disposing); diff --git a/src/Mvc/Mvc.ViewFeatures/src/Buffers/ViewBuffer.cs b/src/Mvc/Mvc.ViewFeatures/src/Buffers/ViewBuffer.cs index 86f0069c555c..23451621b6df 100644 --- a/src/Mvc/Mvc.ViewFeatures/src/Buffers/ViewBuffer.cs +++ b/src/Mvc/Mvc.ViewFeatures/src/Buffers/ViewBuffer.cs @@ -4,7 +4,9 @@ using System.Diagnostics; using System.Runtime.CompilerServices; using System.Text.Encodings.Web; +using System.Text; using Microsoft.AspNetCore.Html; +using Microsoft.AspNetCore.WebUtilities; namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Buffers; @@ -120,6 +122,15 @@ public IHtmlContentBuilder AppendHtml(string encoded) return this; } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal void AppendHtml(ReadOnlyMemory utf8Value) + { + if (!utf8Value.IsEmpty) + { + AppendValue(new ViewBufferValue(utf8Value)); + } + } + // Very common trivial method; nudge it to inline https://github.com/aspnet/Mvc/pull/8339 [MethodImpl(MethodImplOptions.AggressiveInlining)] private void AppendValue(ViewBufferValue value) @@ -187,16 +198,17 @@ public void WriteTo(TextWriter writer, HtmlEncoder encoder) { var value = page.Buffer[j]; - if (value.Value is string valueAsString) + switch (value.ValueType) { - writer.Write(valueAsString); - continue; - } - - if (value.Value is IHtmlContent valueAsHtmlContent) - { - valueAsHtmlContent.WriteTo(writer, encoder); - continue; + case ViewBufferValue.ViewBufferValueType.String: + writer.Write(value.StringValue); + break; + case ViewBufferValue.ViewBufferValueType.Utf8: + WriteUtf8LiteralTo(writer, value.Utf8Value); + break; + case ViewBufferValue.ViewBufferValueType.HtmlContent: + value.HtmlContentValue.WriteTo(writer, encoder); + break; } } } @@ -220,33 +232,68 @@ public async Task WriteToAsync(TextWriter writer, HtmlEncoder encoder) { var value = page.Buffer[j]; - if (value.Value is string valueAsString) + switch (value.ValueType) { - await writer.WriteAsync(valueAsString); - continue; + case ViewBufferValue.ViewBufferValueType.String: + await writer.WriteAsync(value.StringValue); + break; + case ViewBufferValue.ViewBufferValueType.Utf8: + await WriteUtf8LiteralToAsync(writer, value.Utf8Value); + break; + case ViewBufferValue.ViewBufferValueType.HtmlContent: + var valueAsHtmlContent = value.HtmlContentValue; + if (valueAsHtmlContent is ViewBuffer valueAsViewBuffer) + { + await valueAsViewBuffer.WriteToAsync(writer, encoder); + break; + } + + if (valueAsHtmlContent is IHtmlAsyncContent valueAsHtmlAsyncContent) + { + await valueAsHtmlAsyncContent.WriteToAsync(writer); + await writer.FlushAsync(); + break; + } + + valueAsHtmlContent.WriteTo(writer, encoder); + await writer.FlushAsync(); + break; } + } + } + } - if (value.Value is ViewBuffer valueAsViewBuffer) - { - await valueAsViewBuffer.WriteToAsync(writer, encoder); - continue; - } + private static void WriteUtf8LiteralTo(TextWriter writer, ReadOnlyMemory utf8Value) + { + if (writer is PagedBufferedTextWriter pagedWriter) + { + pagedWriter.WriteUtf8(utf8Value.Span); + } + else if (writer is HttpResponseStreamWriter responseWriter) + { + responseWriter.WriteUtf8(utf8Value.Span); + } + else + { + // Fallback: decode to string for writers that don't support direct UTF-8 + writer.Write(Encoding.UTF8.GetString(utf8Value.Span)); + } + } - if (value.Value is IHtmlAsyncContent valueAsHtmlAsyncContent) - { - await valueAsHtmlAsyncContent.WriteToAsync(writer); - await writer.FlushAsync(); - continue; - } + private static Task WriteUtf8LiteralToAsync(TextWriter writer, ReadOnlyMemory utf8Value) + { + if (writer is PagedBufferedTextWriter pagedWriter) + { + return pagedWriter.WriteUtf8Async(utf8Value); + } - if (value.Value is IHtmlContent valueAsHtmlContent) - { - valueAsHtmlContent.WriteTo(writer, encoder); - await writer.FlushAsync(); - continue; - } - } + if (writer is HttpResponseStreamWriter responseWriter) + { + return responseWriter.WriteUtf8Async(utf8Value); } + + // Fallback: decode to string for writers that don't support direct UTF-8 + return writer.WriteAsync(Encoding.UTF8.GetString(utf8Value.Span)); } private string DebuggerToString() => _name; @@ -262,19 +309,25 @@ public void CopyTo(IHtmlContentBuilder destination) { var value = page.Buffer[j]; - string valueAsString; - IHtmlContentContainer valueAsContainer; - if ((valueAsString = value.Value as string) != null) - { - destination.AppendHtml(valueAsString); - } - else if ((valueAsContainer = value.Value as IHtmlContentContainer) != null) - { - valueAsContainer.CopyTo(destination); - } - else + switch (value.ValueType) { - destination.AppendHtml((IHtmlContent)value.Value); + case ViewBufferValue.ViewBufferValueType.String: + destination.AppendHtml(value.StringValue); + break; + case ViewBufferValue.ViewBufferValueType.Utf8: + destination.AppendHtml(Encoding.UTF8.GetString(value.Utf8Value.Span)); + break; + case ViewBufferValue.ViewBufferValueType.HtmlContent: + if (value.HtmlContentValue is IHtmlContentContainer valueAsContainer) + { + valueAsContainer.CopyTo(destination); + } + else + { + destination.AppendHtml(value.HtmlContentValue); + } + + break; } } } @@ -299,19 +352,25 @@ public void MoveTo(IHtmlContentBuilder destination) { var value = page.Buffer[j]; - string valueAsString; - IHtmlContentContainer valueAsContainer; - if ((valueAsString = value.Value as string) != null) - { - destination.AppendHtml(valueAsString); - } - else if ((valueAsContainer = value.Value as IHtmlContentContainer) != null) - { - valueAsContainer.MoveTo(destination); - } - else + switch (value.ValueType) { - destination.AppendHtml((IHtmlContent)value.Value); + case ViewBufferValue.ViewBufferValueType.String: + destination.AppendHtml(value.StringValue); + break; + case ViewBufferValue.ViewBufferValueType.Utf8: + destination.AppendHtml(Encoding.UTF8.GetString(value.Utf8Value.Span)); + break; + case ViewBufferValue.ViewBufferValueType.HtmlContent: + if (value.HtmlContentValue is IHtmlContentContainer valueAsContainer) + { + valueAsContainer.MoveTo(destination); + } + else + { + destination.AppendHtml(value.HtmlContentValue); + } + + break; } } } diff --git a/src/Mvc/Mvc.ViewFeatures/src/Buffers/ViewBufferTextWriter.cs b/src/Mvc/Mvc.ViewFeatures/src/Buffers/ViewBufferTextWriter.cs index 8c93be2ca475..0f2e2f8bbb1a 100644 --- a/src/Mvc/Mvc.ViewFeatures/src/Buffers/ViewBufferTextWriter.cs +++ b/src/Mvc/Mvc.ViewFeatures/src/Buffers/ViewBufferTextWriter.cs @@ -34,6 +34,7 @@ public ViewBufferTextWriter(ViewBuffer buffer, Encoding encoding) Buffer = buffer; Encoding = encoding; + IsUtf8Encoding = ReferenceEquals(encoding, Encoding.UTF8) || encoding is UTF8Encoding; } /// @@ -54,6 +55,7 @@ public ViewBufferTextWriter(ViewBuffer buffer, Encoding encoding, HtmlEncoder ht Buffer = buffer; Encoding = encoding; + IsUtf8Encoding = ReferenceEquals(encoding, Encoding.UTF8) || encoding is UTF8Encoding; _htmlEncoder = htmlEncoder; _inner = inner; } @@ -61,6 +63,8 @@ public ViewBufferTextWriter(ViewBuffer buffer, Encoding encoding, HtmlEncoder ht /// public override Encoding Encoding { get; } + internal bool IsUtf8Encoding { get; } + /// /// Gets the . /// diff --git a/src/Mvc/Mvc.ViewFeatures/src/Buffers/ViewBufferValue.cs b/src/Mvc/Mvc.ViewFeatures/src/Buffers/ViewBufferValue.cs index 3b159cf00ca5..6644a79a6097 100644 --- a/src/Mvc/Mvc.ViewFeatures/src/Buffers/ViewBufferValue.cs +++ b/src/Mvc/Mvc.ViewFeatures/src/Buffers/ViewBufferValue.cs @@ -2,24 +2,31 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; +using System.Text; using System.Text.Encodings.Web; using Microsoft.AspNetCore.Html; namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Buffers; /// -/// Encapsulates a string or value. +/// Encapsulates a string, , or UTF-8 encoded value. /// [DebuggerDisplay("{DebuggerToString()}")] public readonly struct ViewBufferValue { + private readonly object _value; + private readonly ReadOnlyMemory _utf8Value; + private readonly ViewBufferValueType _valueType; + /// /// Initializes a new instance of with a string value. /// /// The value. public ViewBufferValue(string value) { - Value = value; + _value = value; + _utf8Value = default; + _valueType = ViewBufferValueType.String; } /// @@ -28,27 +35,73 @@ public ViewBufferValue(string value) /// The . public ViewBufferValue(IHtmlContent content) { - Value = content; + _value = content; + _utf8Value = default; + _valueType = ViewBufferValueType.HtmlContent; + } + + /// + /// Initializes a new instance of with a UTF-8 encoded value. + /// + /// The UTF-8 encoded value. + public ViewBufferValue(ReadOnlyMemory utf8Value) + { + _value = null; + _utf8Value = utf8Value; + _valueType = ViewBufferValueType.Utf8; + } + + /// + /// Gets a value that indicates whether this instance contains a UTF-8 encoded value. + /// + public bool IsUtf8Value => _valueType == ViewBufferValueType.Utf8; + + /// + /// Gets the UTF-8 encoded value. + /// + public ReadOnlyMemory Utf8Value => _utf8Value; + + internal ViewBufferValueType ValueType => _valueType; + + internal string StringValue => (string)_value; + + internal IHtmlContent HtmlContentValue => (IHtmlContent)_value; + + internal enum ViewBufferValueType : byte + { + None, + String, + HtmlContent, + Utf8, } /// /// Gets the value. /// - public object Value { get; } + /// + /// When is , this converts the UTF-8 value to a string and returns it. Use to access the value directly without conversion. + /// + public object Value => _valueType == ViewBufferValueType.Utf8 ? Encoding.UTF8.GetString(_utf8Value.Span) : _value; private string DebuggerToString() { using (var writer = new StringWriter()) { - if (Value is string valueAsString) + if (_valueType == ViewBufferValueType.String) + { + writer.Write((string)_value); + return writer.ToString(); + } + + if (_valueType == ViewBufferValueType.HtmlContent) { - writer.Write(valueAsString); + ((IHtmlContent)_value).WriteTo(writer, HtmlEncoder.Default); return writer.ToString(); } - if (Value is IHtmlContent valueAsContent) + if (_valueType == ViewBufferValueType.Utf8) { - valueAsContent.WriteTo(writer, HtmlEncoder.Default); + writer.Write(Encoding.UTF8.GetString(_utf8Value.Span)); return writer.ToString(); } diff --git a/src/Mvc/Mvc.ViewFeatures/src/Microsoft.AspNetCore.Mvc.ViewFeatures.csproj b/src/Mvc/Mvc.ViewFeatures/src/Microsoft.AspNetCore.Mvc.ViewFeatures.csproj index 90bf2b0f91f8..fe31705de266 100644 --- a/src/Mvc/Mvc.ViewFeatures/src/Microsoft.AspNetCore.Mvc.ViewFeatures.csproj +++ b/src/Mvc/Mvc.ViewFeatures/src/Microsoft.AspNetCore.Mvc.ViewFeatures.csproj @@ -58,6 +58,7 @@ + diff --git a/src/Mvc/Mvc.ViewFeatures/src/PublicAPI.Unshipped.txt b/src/Mvc/Mvc.ViewFeatures/src/PublicAPI.Unshipped.txt index 7dc5c58110bf..dec46aecd39a 100644 --- a/src/Mvc/Mvc.ViewFeatures/src/PublicAPI.Unshipped.txt +++ b/src/Mvc/Mvc.ViewFeatures/src/PublicAPI.Unshipped.txt @@ -1 +1,4 @@ #nullable enable +Microsoft.AspNetCore.Mvc.ViewFeatures.Buffers.ViewBufferValue.IsUtf8Value.get -> bool +Microsoft.AspNetCore.Mvc.ViewFeatures.Buffers.ViewBufferValue.Utf8Value.get -> System.ReadOnlyMemory +Microsoft.AspNetCore.Mvc.ViewFeatures.Buffers.ViewBufferValue.ViewBufferValue(System.ReadOnlyMemory utf8Value) -> void diff --git a/src/Mvc/Mvc.ViewFeatures/test/Buffers/PagedBufferedTextWriterTest.cs b/src/Mvc/Mvc.ViewFeatures/test/Buffers/PagedBufferedTextWriterTest.cs index d32d70431a5a..5d4bf1bedf56 100644 --- a/src/Mvc/Mvc.ViewFeatures/test/Buffers/PagedBufferedTextWriterTest.cs +++ b/src/Mvc/Mvc.ViewFeatures/test/Buffers/PagedBufferedTextWriterTest.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Buffers; +using Microsoft.AspNetCore.WebUtilities; namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Buffers; @@ -349,4 +350,78 @@ public override void Return(char[] buffer, bool clearArray = false) Returned.Add(buffer); } } + + [Fact] + public void WriteUtf8_WritesBytesDirectlyToInnerWriter() + { + var stream = new MemoryStream(); + var inner = new HttpResponseStreamWriter(stream, System.Text.Encoding.UTF8); + var writer = new PagedBufferedTextWriter(new TestArrayPool(), inner); + + writer.WriteUtf8("

Hello

"u8); + inner.Flush(); + + Assert.Equal("

Hello

", System.Text.Encoding.UTF8.GetString(stream.ToArray())); + } + + [Fact] + public async Task WriteUtf8Async_WritesBytesDirectlyToInnerWriter() + { + var stream = new MemoryStream(); + var inner = new HttpResponseStreamWriter(stream, System.Text.Encoding.UTF8); + var writer = new PagedBufferedTextWriter(new TestArrayPool(), inner); + + await writer.WriteUtf8Async("

Hello

"u8.ToArray()); + await inner.FlushAsync(); + + Assert.Equal("

Hello

", System.Text.Encoding.UTF8.GetString(stream.ToArray())); + } + + [Fact] + public async Task WriteUtf8Async_FlushesBufferedCharsFirst_MaintainsOrdering() + { + var stream = new MemoryStream(); + var inner = new HttpResponseStreamWriter(stream, System.Text.Encoding.UTF8); + var writer = new PagedBufferedTextWriter(new TestArrayPool(), inner); + + writer.Write("Hello "); + await writer.WriteUtf8Async("World"u8.ToArray()); + await writer.FlushAsync(); + await inner.FlushAsync(); + + var output = System.Text.Encoding.UTF8.GetString(stream.ToArray()); + Assert.Equal("Hello World", output); + } + + [Fact] + public async Task WriteUtf8Async_MixedCharsAndBytes_InterleavedCorrectly() + { + var stream = new MemoryStream(); + var inner = new HttpResponseStreamWriter(stream, System.Text.Encoding.UTF8); + var writer = new PagedBufferedTextWriter(new TestArrayPool(), inner); + + writer.Write(""); + await writer.WriteUtf8Async(""u8.ToArray()); + writer.Write("T"); + await writer.WriteUtf8Async(""u8.ToArray()); + writer.Write(""); + await writer.FlushAsync(); + await inner.FlushAsync(); + + var output = System.Text.Encoding.UTF8.GetString(stream.ToArray()); + Assert.Equal("T", output); + } + + [Fact] + public async Task WriteUtf8Async_FallsBackToString_WhenInnerIsNotHttpResponseStreamWriter() + { + var inner = new StringWriter(); + var writer = new PagedBufferedTextWriter(new TestArrayPool(), inner); + + writer.Write("prefix "); + await writer.WriteUtf8Async("

test

"u8.ToArray()); + await writer.FlushAsync(); + + Assert.Equal("prefix

test

", inner.ToString()); + } } diff --git a/src/Mvc/Mvc.ViewFeatures/test/Buffers/ViewBufferTest.cs b/src/Mvc/Mvc.ViewFeatures/test/Buffers/ViewBufferTest.cs index 8c2f471246c9..9517b7d8eee5 100644 --- a/src/Mvc/Mvc.ViewFeatures/test/Buffers/ViewBufferTest.cs +++ b/src/Mvc/Mvc.ViewFeatures/test/Buffers/ViewBufferTest.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.Buffers; using System.Globalization; using Microsoft.AspNetCore.Html; +using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.WebEncoders.Testing; namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Buffers; @@ -509,4 +511,181 @@ public void MoveTo_ViewBuffer_MultiplePages() item => Assert.Null(item.Value), item => Assert.Null(item.Value)); } + + [Fact] + public void AppendHtml_Utf8Value_AddsToBuffer() + { + var buffer = new ViewBuffer(new TestViewBufferScope(), "some-name", pageSize: 32); + var utf8Content = System.Text.Encoding.UTF8.GetBytes("

Hello

"); + + buffer.AppendHtml(utf8Content); + + Assert.Equal(1, buffer.Count); + var page = buffer[0]; + Assert.Equal(1, page.Count); + Assert.True(page.Buffer[0].IsUtf8Value); + Assert.True(utf8Content.AsSpan().SequenceEqual(page.Buffer[0].Utf8Value.Span)); + } + + [Fact] + public void WriteTo_WithUtf8Value_WritesDecodedContent() + { + var buffer = new ViewBuffer(new TestViewBufferScope(), "some-name", pageSize: 32); + var writer = new StringWriter(); + + buffer.AppendHtml("prefix "); + buffer.AppendHtml(System.Text.Encoding.UTF8.GetBytes("

UTF-8

")); + buffer.AppendHtml(" suffix"); + buffer.WriteTo(writer, new HtmlTestEncoder()); + + Assert.Equal("prefix

UTF-8

suffix", writer.ToString()); + } + + [Fact] + public async Task WriteToAsync_WithUtf8Value_WritesDecodedContent() + { + var buffer = new ViewBuffer(new TestViewBufferScope(), "some-name", pageSize: 32); + var writer = new StringWriter(); + + buffer.AppendHtml("prefix "); + buffer.AppendHtml(System.Text.Encoding.UTF8.GetBytes("

UTF-8

")); + buffer.AppendHtml(" suffix"); + + await buffer.WriteToAsync(writer, new HtmlTestEncoder()); + + Assert.Equal("prefix

UTF-8

suffix", writer.ToString()); + } + + [Fact] + public void WriteTo_MixedStringAndUtf8Content_WritesCorrectly() + { + var buffer = new ViewBuffer(new TestViewBufferScope(), "some-name", pageSize: 32); + var writer = new StringWriter(); + + buffer.AppendHtml(""); + buffer.AppendHtml(System.Text.Encoding.UTF8.GetBytes("Test")); + buffer.AppendHtml(""); + buffer.AppendHtml(System.Text.Encoding.UTF8.GetBytes("

Hello

")); + buffer.AppendHtml(""); + + buffer.WriteTo(writer, new HtmlTestEncoder()); + + Assert.Equal("Test

Hello

", writer.ToString()); + } + + [Fact] + public async Task WriteToAsync_MixedStringAndUtf8Content_WritesCorrectly() + { + var buffer = new ViewBuffer(new TestViewBufferScope(), "some-name", pageSize: 32); + var writer = new StringWriter(); + + buffer.AppendHtml(""); + buffer.AppendHtml(System.Text.Encoding.UTF8.GetBytes("Test")); + buffer.AppendHtml(""); + buffer.AppendHtml(System.Text.Encoding.UTF8.GetBytes("

Hello

")); + buffer.AppendHtml(""); + + await buffer.WriteToAsync(writer, new HtmlTestEncoder()); + + Assert.Equal("Test

Hello

", writer.ToString()); + } + + [Fact] + public async Task WriteToAsync_Utf8Value_DoesNotFlushPerItem() + { + var buffer = new ViewBuffer(new TestViewBufferScope(), "some-name", pageSize: 32); + var flushTrackingWriter = new FlushTrackingWriter(); + + buffer.AppendHtml(System.Text.Encoding.UTF8.GetBytes("

One

")); + buffer.AppendHtml(System.Text.Encoding.UTF8.GetBytes("

Two

")); + buffer.AppendHtml(System.Text.Encoding.UTF8.GetBytes("

Three

")); + + await buffer.WriteToAsync(flushTrackingWriter, new HtmlTestEncoder()); + + // UTF-8 literals should NOT trigger per-item flushes like generic IHtmlContent does + Assert.Equal(0, flushTrackingWriter.FlushCount); + Assert.Equal("

One

Two

Three

", flushTrackingWriter.ToString()); + } + + [Fact] + public void WriteTo_Utf8Literal_WritesDirectlyToStream_NoStringConversion() + { + var buffer = new ViewBuffer(new TestViewBufferScope(), "some-name", pageSize: 32); + var stream = new MemoryStream(); + var responseWriter = new HttpResponseStreamWriter(stream, System.Text.Encoding.UTF8); + var pagedWriter = new PagedBufferedTextWriter(ArrayPool.Shared, responseWriter); + + var utf8Bytes = "

Direct UTF-8

"u8.ToArray(); + buffer.AppendHtml(utf8Bytes); + + buffer.WriteTo(pagedWriter, new HtmlTestEncoder()); + responseWriter.Flush(); + + // Verify the exact bytes made it to the stream + Assert.Equal(utf8Bytes, stream.ToArray()); + } + + [Fact] + public async Task WriteToAsync_Utf8Literal_WritesDirectlyToStream_NoStringConversion() + { + var buffer = new ViewBuffer(new TestViewBufferScope(), "some-name", pageSize: 32); + var stream = new MemoryStream(); + var responseWriter = new HttpResponseStreamWriter(stream, System.Text.Encoding.UTF8); + var pagedWriter = new PagedBufferedTextWriter(ArrayPool.Shared, responseWriter); + + var utf8Bytes = "

Direct UTF-8

"u8.ToArray(); + buffer.AppendHtml(utf8Bytes); + + await buffer.WriteToAsync(pagedWriter, new HtmlTestEncoder()); + await pagedWriter.FlushAsync(); + await responseWriter.FlushAsync(); + + // Verify the exact bytes made it to the stream + Assert.Equal(utf8Bytes, stream.ToArray()); + } + + [Fact] + public async Task WriteToAsync_MixedStringAndUtf8_EndToEnd_MaintainsOrdering() + { + var buffer = new ViewBuffer(new TestViewBufferScope(), "some-name", pageSize: 32); + var stream = new MemoryStream(); + var responseWriter = new HttpResponseStreamWriter(stream, System.Text.Encoding.UTF8); + var pagedWriter = new PagedBufferedTextWriter(ArrayPool.Shared, responseWriter); + + buffer.AppendHtml(""); + buffer.AppendHtml(System.Text.Encoding.UTF8.GetBytes("")); + buffer.AppendHtml("Test"); + buffer.AppendHtml(System.Text.Encoding.UTF8.GetBytes("")); + buffer.AppendHtml(""); + + await buffer.WriteToAsync(pagedWriter, new HtmlTestEncoder()); + await pagedWriter.FlushAsync(); + await responseWriter.FlushAsync(); + + var output = System.Text.Encoding.UTF8.GetString(stream.ToArray()); + Assert.Equal("Test", output); + } + + private class FlushTrackingWriter : StringWriter + { + public int FlushCount { get; private set; } + + public override void Flush() + { + FlushCount++; + base.Flush(); + } + + public override Task FlushAsync() + { + FlushCount++; + return base.FlushAsync(); + } + + public override Task FlushAsync(CancellationToken cancellationToken) + { + FlushCount++; + return base.FlushAsync(cancellationToken); + } + } } diff --git a/src/Mvc/Mvc.ViewFeatures/test/Buffers/ViewBufferValueTest.cs b/src/Mvc/Mvc.ViewFeatures/test/Buffers/ViewBufferValueTest.cs new file mode 100644 index 000000000000..9cfe45de51b5 --- /dev/null +++ b/src/Mvc/Mvc.ViewFeatures/test/Buffers/ViewBufferValueTest.cs @@ -0,0 +1,51 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text; +using Microsoft.AspNetCore.Html; + +namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Buffers; + +public class ViewBufferValueTest +{ + [Fact] + public void Utf8Value_RoundTripsWithoutBoxing() + { + var utf8Bytes = "

Hello World

"u8.ToArray(); + var value = new ViewBufferValue(utf8Bytes); + + Assert.True(value.IsUtf8Value); + Assert.True(utf8Bytes.AsSpan().SequenceEqual(value.Utf8Value.Span)); + } + + [Fact] + public void Value_WithUtf8Value_ReturnsBoxedReadOnlyMemory() + { + var utf8Bytes = "

Hello World

"u8.ToArray(); + var value = new ViewBufferValue(utf8Bytes); + + var boxedValue = Assert.IsType>(value.Value); + Assert.True(utf8Bytes.AsSpan().SequenceEqual(boxedValue.Span)); + } + + [Fact] + public void Value_WithStringValue_ReturnsString() + { + var value = new ViewBufferValue("Hello World"); + + Assert.False(value.IsUtf8Value); + Assert.Equal("Hello World", Assert.IsType(value.Value)); + Assert.True(value.Utf8Value.IsEmpty); + } + + [Fact] + public void Value_WithHtmlContent_ReturnsHtmlContent() + { + var htmlContent = new HtmlString("

Hello World

"); + var value = new ViewBufferValue(htmlContent); + + Assert.False(value.IsUtf8Value); + Assert.Same(htmlContent, Assert.IsAssignableFrom(value.Value)); + Assert.True(value.Utf8Value.IsEmpty); + } +} diff --git a/src/Mvc/Mvc.ViewFeatures/test/ViewExecutorTest.cs b/src/Mvc/Mvc.ViewFeatures/test/ViewExecutorTest.cs index 7b53bce6cf0a..7b9ef780aa26 100644 --- a/src/Mvc/Mvc.ViewFeatures/test/ViewExecutorTest.cs +++ b/src/Mvc/Mvc.ViewFeatures/test/ViewExecutorTest.cs @@ -342,8 +342,6 @@ public async Task ExecuteAsync_AsynchronouslyFlushesToTheResponseStream_PriorToD await v.Writer.WriteAsync(text); }); - var expectedWriteCallCount = Math.Ceiling((double)writeLength / TestHttpResponseStreamWriterFactory.DefaultBufferSize); - var context = new DefaultHttpContext(); var stream = new Mock(); stream.SetupGet(s => s.CanWrite).Returns(true); @@ -368,9 +366,11 @@ await viewExecutor.ExecuteAsync( // Assert stream.Verify(s => s.FlushAsync(It.IsAny()), Times.Never()); + // Byte buffering may coalesce multiple char-encoded batches into fewer stream writes, + // but all data must be written and at least one write must occur. stream.Verify( s => s.WriteAsync(It.IsAny>(), It.IsAny()), - Times.Exactly((int)expectedWriteCallCount)); + Times.AtLeastOnce()); stream.Verify( s => s.WriteAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never()); diff --git a/src/Mvc/perf/Microbenchmarks/Microsoft.AspNetCore.Mvc/Microsoft.AspNetCore.Mvc.Microbenchmarks.csproj b/src/Mvc/perf/Microbenchmarks/Microsoft.AspNetCore.Mvc/Microsoft.AspNetCore.Mvc.Microbenchmarks.csproj index d73e58255130..e717e2a77f00 100644 --- a/src/Mvc/perf/Microbenchmarks/Microsoft.AspNetCore.Mvc/Microsoft.AspNetCore.Mvc.Microbenchmarks.csproj +++ b/src/Mvc/perf/Microbenchmarks/Microsoft.AspNetCore.Mvc/Microsoft.AspNetCore.Mvc.Microbenchmarks.csproj @@ -16,6 +16,7 @@ + diff --git a/src/Mvc/perf/Microbenchmarks/Microsoft.AspNetCore.Mvc/RuntimePerformanceBenchmarkBase.cs b/src/Mvc/perf/Microbenchmarks/Microsoft.AspNetCore.Mvc/RuntimePerformanceBenchmarkBase.cs index 5a1a80cf6ed0..59a01fe7fb7e 100644 --- a/src/Mvc/perf/Microbenchmarks/Microsoft.AspNetCore.Mvc/RuntimePerformanceBenchmarkBase.cs +++ b/src/Mvc/perf/Microbenchmarks/Microsoft.AspNetCore.Mvc/RuntimePerformanceBenchmarkBase.cs @@ -86,6 +86,11 @@ public BenchmarkHostingEnvironment() public IFileProvider ContentRootFileProvider { get; set; } } + public RuntimePerformanceBenchmarkBase() : this([]) + { + + } + protected RuntimePerformanceBenchmarkBase(params string[] viewPaths) { ViewPaths = viewPaths; diff --git a/src/Mvc/perf/Microbenchmarks/Microsoft.AspNetCore.Mvc/WriteLiteralUtf8Benchmark.cs b/src/Mvc/perf/Microbenchmarks/Microsoft.AspNetCore.Mvc/WriteLiteralUtf8Benchmark.cs new file mode 100644 index 000000000000..1b275c6e5d72 --- /dev/null +++ b/src/Mvc/perf/Microbenchmarks/Microsoft.AspNetCore.Mvc/WriteLiteralUtf8Benchmark.cs @@ -0,0 +1,188 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Encodings.Web; +using BenchmarkDotNet.Attributes; +using Microsoft.AspNetCore.Html; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.Razor; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.Mvc.ViewEngines; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.AspNetCore.Mvc.ViewFeatures.Buffers; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace Microsoft.AspNetCore.Mvc.Microbenchmarks; + +/// +/// Benchmarks comparing WriteLiteral(string) vs WriteLiteral(ReadOnlyMemory<byte>) +/// through the full MVC view rendering pipeline: ViewBuffer → PagedBufferedTextWriter → +/// HttpResponseStreamWriter → Stream. +/// +[MemoryDiagnoser] +public class WriteLiteralUtf8Benchmark +{ + private MemoryStream _outputStream; + + [GlobalSetup] + public void Setup() + { + _outputStream = new MemoryStream(capacity: 16 * 1024); + } + + [GlobalCleanup] + public void Cleanup() + { + _outputStream.Dispose(); + } + + /// + /// Baseline: renders a view using the existing WriteLiteral(string) path. + /// HTML literals are stored as strings and go through char-to-byte encoding at flush time. + /// + [Benchmark(Description = "WriteLiteral(string)", Baseline = true)] + public async Task WriteLiteral_String() + { + _outputStream.Position = 0; + _outputStream.SetLength(0); + + var view = new StringWriteLiteralView(); + await RenderViewAsync(view, _outputStream); + } + + /// + /// New: renders a view using WriteLiteral(ReadOnlyMemory<byte>). + /// UTF-8 literal bytes flow directly to the response stream with zero string conversion. + /// + [Benchmark(Description = "WriteLiteral(ROM)")] + public async Task WriteLiteral_Utf8() + { + _outputStream.Position = 0; + _outputStream.SetLength(0); + + var view = new Utf8WriteLiteralView(); + await RenderViewAsync(view, _outputStream); + } + + private static async Task RenderViewAsync(RazorPage page, Stream outputStream) + { + var bufferScope = new BenchmarkViewBufferScope(); + var buffer = new ViewBuffer(bufferScope, "benchmark-view", ViewBuffer.ViewPageSize); + var viewBufferWriter = new ViewBufferTextWriter(buffer, Encoding.UTF8); + + var httpContext = new DefaultHttpContext + { + RequestServices = new ServiceCollection() + .AddSingleton(bufferScope) + .BuildServiceProvider() + }; + + var viewContext = new ViewContext( + new ActionContext(httpContext, new RouteData(), new ActionDescriptor()), + Mock.Of(), + new ViewDataDictionary(new EmptyModelMetadataProvider(), new ModelStateDictionary()), + Mock.Of(), + viewBufferWriter, + new HtmlHelperOptions()); + + page.ViewContext = viewContext; + page.HtmlEncoder = HtmlEncoder.Default; + + // Execute the view (populates the ViewBuffer) + await page.ExecuteAsync(); + + // Flush through the real writer chain: ViewBuffer → PagedBufferedTextWriter → HttpResponseStreamWriter → Stream + using var responseWriter = new HttpResponseStreamWriter(outputStream, Encoding.UTF8); + await using var pagedWriter = new PagedBufferedTextWriter(ArrayPool.Shared, responseWriter); + await buffer.WriteToAsync(pagedWriter, HtmlEncoder.Default); + await pagedWriter.FlushAsync(); + } + + // Simulated view using WriteLiteral(string) — the existing path + [CompilerGenerated] + private sealed class StringWriteLiteralView : RazorPage + { + // Simulates a typical product listing page with repeated HTML structure + public override Task ExecuteAsync() + { + WriteLiteral("\r\n\r\n\r\n \r\n \r\n Product Listing\r\n \r\n\r\n\r\n
\r\n \r\n
\r\n
\r\n
\r\n

Products

\r\n
\r\n"); + + for (var i = 0; i < 500; i++) + { + WriteLiteral("
\r\n
\r\n
\r\n
"); + Write("Model.Name"); // Simulates @Model.Name + WriteLiteral("
\r\n

"); + Write("Model.Description that's longer and needs more work"); // Simulates @Model.Description + WriteLiteral("

\r\n
\r\n "); + Write(123.45); // Simulates @Model.Price + WriteLiteral("\r\n View Details\r\n
\r\n
\r\n
\r\n
\r\n"); + } + + WriteLiteral("
\r\n
\r\n
\r\n
\r\n
\r\n © 2026 - My Store - Privacy\r\n
\r\n
\r\n \r\n\r\n"); + + return Task.CompletedTask; + } + } + + // Simulated view using WriteLiteral(ReadOnlyMemory) — the new UTF-8 path + [CompilerGenerated] + private sealed class Utf8WriteLiteralView : RazorPage + { + private static class __Literals + { + public static readonly byte[] Literal_0 = "\r\n\r\n\r\n \r\n \r\n Product Listing\r\n \r\n\r\n\r\n
\r\n \r\n
\r\n
\r\n
\r\n

Products

\r\n
\r\n"u8.ToArray(); + public static readonly byte[] Literal_1 = "
\r\n
\r\n
\r\n
"u8.ToArray(); + public static readonly byte[] Literal_2 = "
\r\n

"u8.ToArray(); + public static readonly byte[] Literal_3 = "

\r\n
\r\n "u8.ToArray(); + public static readonly byte[] Literal_4 = "\r\n View Details\r\n
\r\n
\r\n
\r\n
\r\n"u8.ToArray(); + public static readonly byte[] Literal_6 = "
\r\n
\r\n
\r\n
\r\n
\r\n © 2026 - My Store - Privacy\r\n
\r\n
\r\n \r\n\r\n"u8.ToArray(); + } + + public override Task ExecuteAsync() + { + WriteLiteral(__Literals.Literal_0); + + for (var i = 0; i < 500; i++) + { + WriteLiteral(__Literals.Literal_1); + Write("Model.Name"); // Simulates @Model.Name + WriteLiteral(__Literals.Literal_2); + Write("Model.Description that's longer and needs more work"); // Simulates @Model.Description + WriteLiteral(__Literals.Literal_3); + Write(123.45); // Simulates @Model.Price + WriteLiteral(__Literals.Literal_4); + Write(123456); // Simulates @Model.Id + WriteLiteral(__Literals.Literal_5); + } + + WriteLiteral(__Literals.Literal_6); + + return Task.CompletedTask; + } + } + + // Minimal IViewBufferScope for benchmarks — avoids DI container overhead + private sealed class BenchmarkViewBufferScope : IViewBufferScope + { + public ViewBufferValue[] GetPage(int size) => new ViewBufferValue[size]; + + public void ReturnSegment(ViewBufferValue[] segment) + { + Array.Clear(segment, 0, segment.Length); + } + + public TextWriter CreateWriter(TextWriter writer) => + new PagedBufferedTextWriter(ArrayPool.Shared, writer); + } +} diff --git a/src/Mvc/perf/Microbenchmarks/Microsoft.AspNetCore.Mvc/readme.md b/src/Mvc/perf/Microbenchmarks/Microsoft.AspNetCore.Mvc/readme.md index 8adc74351b8b..88ee2a2eaba3 100644 --- a/src/Mvc/perf/Microbenchmarks/Microsoft.AspNetCore.Mvc/readme.md +++ b/src/Mvc/perf/Microbenchmarks/Microsoft.AspNetCore.Mvc/readme.md @@ -1,11 +1,27 @@ Compile the solution in Release mode (so binaries are available in release) -To run a specific benchmark add it as parameter +To run all benchmarks matching a name filter: + +``` +dotnet run -c Release -- --filter ** +``` + +e.g. to run all benchmarks in the `WriteLiteralUtf8Benchmark` class: + +``` +dotnet run -c Release -- --filter *WriteLiteralUtf8Benchmark* ``` -dotnet run -c Release + +To run a specific benchmark specify its full name as parameter: + +``` +dotnet run -c Release -- ``` -To run all use `All` as parameter + +To run all use `All` as parameter: + ``` -dotnet run -c Release All +dotnet run -c Release -- All ``` -Using no parameter will list all available benchmarks \ No newline at end of file + +Using no parameter will list all available benchmarks and allow to select one. \ No newline at end of file diff --git a/src/Shared/BenchmarkRunner/DefaultCoreConfig.cs b/src/Shared/BenchmarkRunner/DefaultCoreConfig.cs index f13fc883f701..f585dac4a5d3 100644 --- a/src/Shared/BenchmarkRunner/DefaultCoreConfig.cs +++ b/src/Shared/BenchmarkRunner/DefaultCoreConfig.cs @@ -48,6 +48,7 @@ public DefaultCoreConfig() .WithToolchain(CsProjCoreToolchain.From(new NetCoreAppSettings("net10.0", null, ".NET Core 10.0"))) #elif NET11_0 .WithToolchain(CsProjCoreToolchain.From(new NetCoreAppSettings("net11.0", null, ".NET Core 11.0"))) + .WithRuntime(Net11Runtime.Instance) #else #error Target frameworks need to be updated. #endif diff --git a/src/Shared/BenchmarkRunner/DefaultCorePerfLabConfig.cs b/src/Shared/BenchmarkRunner/DefaultCorePerfLabConfig.cs index d13ae29069ff..5b9075daad1d 100644 --- a/src/Shared/BenchmarkRunner/DefaultCorePerfLabConfig.cs +++ b/src/Shared/BenchmarkRunner/DefaultCorePerfLabConfig.cs @@ -10,6 +10,7 @@ using BenchmarkDotNet.Jobs; using BenchmarkDotNet.Loggers; using BenchmarkDotNet.Validators; +using Perfolizer.Metrology; namespace BenchmarkDotNet.Attributes; diff --git a/src/Shared/BenchmarkRunner/Net11Runtime.cs b/src/Shared/BenchmarkRunner/Net11Runtime.cs new file mode 100644 index 000000000000..487e49da51c1 --- /dev/null +++ b/src/Shared/BenchmarkRunner/Net11Runtime.cs @@ -0,0 +1,17 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using BenchmarkDotNet.Environments; +using BenchmarkDotNet.Jobs; + +namespace BenchmarkDotNet.Attributes; + +internal sealed class Net11Runtime : Runtime +{ + public static readonly Net11Runtime Instance = new(); + + private Net11Runtime() + : base(RuntimeMoniker.Net10_0, "net11.0", ".NET 11.0") + { + } +}