diff --git a/src/RazorSlices/BufferWriterHtmlExtensions.cs b/src/RazorSlices/BufferWriterHtmlExtensions.cs new file mode 100644 index 0000000..b5ee4c2 --- /dev/null +++ b/src/RazorSlices/BufferWriterHtmlExtensions.cs @@ -0,0 +1,285 @@ +using System.Buffers.Text; +using System.Diagnostics; +using System.Text.Encodings.Web; +using System.Text.Unicode; +using Microsoft.AspNetCore.Razor.TagHelpers; +using RazorSlices; + +namespace System.Buffers; + +internal static class BufferWriterHtmlExtensions +{ + public static void HtmlEncodeAndWriteUtf8(this IBufferWriter bufferWriter, ReadOnlySpan utf8Text, HtmlEncoder htmlEncoder) + { + if (utf8Text.Length == 0) + { + return; + } + + if (htmlEncoder == NullHtmlEncoder.Default) + { + // No HTML encoding required + bufferWriter.Write(utf8Text); + return; + } + + Span writerSpan = default; + var encodeStatus = OperationStatus.Done; + var waitingToAdvance = 0; + + while (utf8Text.Length > 0) + { + if (writerSpan.Length == 0) + { + if (waitingToAdvance > 0) + { + bufferWriter.Advance(waitingToAdvance); + waitingToAdvance = 0; + } + // Allow space for HTML encoding the string + var spanSizeHint = BufferSizes.GetHtmlEncodedSizeHint(utf8Text.Length); + writerSpan = bufferWriter.GetSpan(spanSizeHint); + } + + // Encode to buffer + encodeStatus = htmlEncoder.EncodeUtf8(utf8Text, writerSpan, out var bytesConsumed, out var bytesWritten); + waitingToAdvance += bytesWritten; + + if (utf8Text.Length - bytesConsumed == 0) + { + break; + } + + utf8Text = utf8Text[bytesConsumed..]; + writerSpan = writerSpan[bytesWritten..]; + } + + if (waitingToAdvance > 0) + { + bufferWriter.Advance(waitingToAdvance); + } + + Debug.Assert(encodeStatus == OperationStatus.Done, "Bad math in IBufferWriter HTML writing extensions"); + } + + public static void HtmlEncodeAndWriteSpanFormattable(this IBufferWriter bufferWriter, T? formattable, HtmlEncoder htmlEncoder, ReadOnlySpan format = default, IFormatProvider? formatProvider = null) + where T : ISpanFormattable + { + if (formattable is null) + { + return; + } + + if (TryHtmlEncodeAndWriteSpanFormattableSmall(bufferWriter, formattable, htmlEncoder, format, formatProvider)) + { + return; + } + + var bufferSize = BufferSizes.SmallFormattableWriteCharSize * 2; + var rentedBuffer = ArrayPool.Shared.Rent(bufferSize); + int charsWritten; + + while (!formattable.TryFormat(rentedBuffer, out charsWritten, format, formatProvider)) + { + // Buffer was too small, return the current buffer and rent a new buffer twice the size + bufferSize = rentedBuffer.Length * 2; + ArrayPool.Shared.Return(rentedBuffer); + rentedBuffer = ArrayPool.Shared.Rent(bufferSize); + } + + HtmlEncodeAndWrite(bufferWriter, rentedBuffer.AsSpan()[..charsWritten], htmlEncoder); + ArrayPool.Shared.Return(rentedBuffer); + } + + private static bool TryHtmlEncodeAndWriteSpanFormattableSmall(IBufferWriter bufferWriter, T formattable, HtmlEncoder htmlEncoder, ReadOnlySpan format = default, IFormatProvider? formatProvider = null) + where T : ISpanFormattable + { + Span buffer = stackalloc char[BufferSizes.SmallFormattableWriteCharSize]; + if (formattable.TryFormat(buffer, out var charsWritten, format, formatProvider)) + { + HtmlEncodeAndWrite(bufferWriter, buffer[..charsWritten], htmlEncoder); + return true; + } + return false; + } + +#if NET8_0_OR_GREATER + public static void HtmlEncodeAndWriteUtf8SpanFormattable(this IBufferWriter bufferWriter, T? formattable, HtmlEncoder htmlEncoder, ReadOnlySpan format = default, IFormatProvider? formatProvider = null) + where T : IUtf8SpanFormattable + { + if (formattable is null) + { + return; + } + + if (TryHtmlEncodeAndWriteUtf8SpanFormattableSmall(bufferWriter, formattable, htmlEncoder, format, formatProvider)) + { + return; + } + + var bufferSize = BufferSizes.SmallFormattableWriteByteSize * 2; + var rentedBuffer = ArrayPool.Shared.Rent(bufferSize); + int bytesWritten; + + while (!formattable.TryFormat(rentedBuffer, out bytesWritten, format, formatProvider)) + { + // Buffer was too small, return the current buffer and rent a new buffer twice the size + bufferSize = rentedBuffer.Length * 2; + ArrayPool.Shared.Return(rentedBuffer); + rentedBuffer = ArrayPool.Shared.Rent(bufferSize); + } + + HtmlEncodeAndWriteUtf8(bufferWriter, rentedBuffer.AsSpan()[..bytesWritten], htmlEncoder); + ArrayPool.Shared.Return(rentedBuffer); + } + + private static bool TryHtmlEncodeAndWriteUtf8SpanFormattableSmall(IBufferWriter bufferWriter, T formattable, HtmlEncoder htmlEncoder, ReadOnlySpan format = default, IFormatProvider? formatProvider = null) + where T : IUtf8SpanFormattable + { + Span buffer = stackalloc byte[BufferSizes.SmallFormattableWriteByteSize]; + if (formattable.TryFormat(buffer, out var bytesWritten, format, formatProvider)) + { + HtmlEncodeAndWriteUtf8(bufferWriter, buffer[..bytesWritten], htmlEncoder); + return true; + } + return false; + } +#endif + + public static void HtmlEncodeAndWrite(this IBufferWriter bufferWriter, ReadOnlySpan textSpan, HtmlEncoder htmlEncoder) + { + if (textSpan.Length == 0) + { + return; + } + + if (htmlEncoder == NullHtmlEncoder.Default) + { + // No HTML encoding required + WriteHtml(bufferWriter, textSpan); + return; + } + + if (textSpan.Length <= BufferSizes.SmallTextWriteCharSize) + { + HtmlEncodeAndWriteSmall(bufferWriter, textSpan, htmlEncoder); + return; + } + + var sizeHint = BufferSizes.GetHtmlEncodedSizeHint(textSpan.Length); + var rentedBuffer = ArrayPool.Shared.Rent(sizeHint); + Span bufferSpan = rentedBuffer; + var waitingToWrite = 0; + var encodeStatus = OperationStatus.Done; + + while (textSpan.Length > 0) + { + if (bufferSpan.Length == 0) + { + if (waitingToWrite > 0) + { + WriteHtml(bufferWriter, rentedBuffer.AsSpan()[..waitingToWrite]); + waitingToWrite = 0; + bufferSpan = rentedBuffer; + } + } + + // Encode to rented buffer + encodeStatus = htmlEncoder.Encode(textSpan, bufferSpan, out var charsConsumed, out var charsWritten); + waitingToWrite += charsWritten; + + if (textSpan.Length - charsConsumed == 0) + { + break; + } + + textSpan = textSpan[charsConsumed..]; + bufferSpan = bufferSpan[charsWritten..]; + } + + if (waitingToWrite > 0) + { + WriteHtml(bufferWriter, rentedBuffer.AsSpan()[..waitingToWrite]); + } + + ArrayPool.Shared.Return(rentedBuffer); + + Debug.Assert(encodeStatus == OperationStatus.Done, "Bad math in IBufferWriter HTML writing extensions"); + } + + private static void HtmlEncodeAndWriteSmall(IBufferWriter bufferWriter, ReadOnlySpan textSpan, HtmlEncoder htmlEncoder) + { + Span encodedBuffer = stackalloc char[BufferSizes.SmallTextWriteCharSize]; + var encodeStatus = OperationStatus.Done; + + // It's possible for encoding to take multiple cycles if an unusually high number of chars need HTML encoding and/or the + // the total number of chars is close to the SmallWriteCharSize limit. + while (textSpan.Length > 0) + { + // Encode to buffer + encodeStatus = htmlEncoder.Encode(textSpan, encodedBuffer, out var charsConsumed, out var charsWritten); + + Debug.Assert(encodeStatus != OperationStatus.NeedMoreData, "Not expecting to hit this case"); + + // Write encoded chars to the writer + var encoded = encodedBuffer[..charsWritten]; + WriteHtml(bufferWriter, encoded); + + textSpan = textSpan[charsConsumed..]; + } + + Debug.Assert(encodeStatus == OperationStatus.Done, "Bad math in IBufferWriter HTML writing extensions"); + } + + public static void WriteHtml(this IBufferWriter bufferWriter, ReadOnlySpan html) + { + Span writerSpan = default; + + var status = OperationStatus.Done; + int waitingToAdvance = 0; + + while (html.Length > 0) + { + if (writerSpan.Length == 0) + { + if (waitingToAdvance > 0) + { + bufferWriter.Advance(waitingToAdvance); + waitingToAdvance = 0; + } + var spanLengthHint = Math.Min(html.Length, BufferSizes.MaxBufferSize); + writerSpan = bufferWriter.GetSpan(spanLengthHint); + } + + status = Utf8.FromUtf16(html, writerSpan, out var charsRead, out var bytesWritten); + waitingToAdvance += bytesWritten; + + if (html.Length - charsRead == 0) + { + break; + } + + html = html[charsRead..]; + writerSpan = writerSpan[bytesWritten..]; + } + + if (waitingToAdvance > 0) + { + bufferWriter.Advance(waitingToAdvance); + } + + Debug.Assert(status == OperationStatus.Done, "Bad math in IBufferWriter HTML writing extensions"); + } + + private static readonly int TrueStringLength = bool.TrueString.Length; + private static readonly int FalseStringLength = bool.FalseString.Length; + + public static void Write(this IBufferWriter bufferWriter, bool value) + { + var buffer = bufferWriter.GetSpan(value ? TrueStringLength : FalseStringLength); + if (!Utf8Formatter.TryFormat(value, buffer, out var _)) + { + throw new FormatException("Unexpectedly insufficient space in buffer to format bool value."); + } + } +} \ No newline at end of file diff --git a/src/RazorSlices/RazorSlice.Partials.cs b/src/RazorSlices/RazorSlice.Partials.cs index 15bcea9..538dd01 100644 --- a/src/RazorSlices/RazorSlice.Partials.cs +++ b/src/RazorSlices/RazorSlice.Partials.cs @@ -87,6 +87,10 @@ internal ValueTask RenderChildSliceAsync(RazorSlice child) { renderPartialTask = child.RenderToTextWriterAsync(_textWriter, _htmlEncoder, CancellationToken, renderLayout: false); } + else if (_bufferWriter is not null) + { + renderPartialTask = child.RenderToBufferWriterAsync(_bufferWriter, _htmlEncoder, CancellationToken, renderLayout: false); + } #pragma warning restore CA2012 else { diff --git a/src/RazorSlices/RazorSlice.Write.cs b/src/RazorSlices/RazorSlice.Write.cs index 15f44aa..82d4b0f 100644 --- a/src/RazorSlices/RazorSlice.Write.cs +++ b/src/RazorSlices/RazorSlice.Write.cs @@ -25,6 +25,7 @@ protected void WriteLiteral(string? value) _pipeWriter?.WriteHtml(value.AsSpan()); _textWriter?.Write(value); + _bufferWriter?.WriteHtml(value.AsSpan()); } /// @@ -75,6 +76,7 @@ protected void WriteLiteral(ReadOnlySpan value) _pipeWriter?.Write(value); _textWriter?.WriteUtf8(value); + _bufferWriter?.Write(value); } /// @@ -119,6 +121,7 @@ protected void Write(ReadOnlySpan value) _pipeWriter?.HtmlEncodeAndWriteUtf8(value, _htmlEncoder); _textWriter?.HtmlEncodeAndWriteUtf8(value, _htmlEncoder); + _bufferWriter?.HtmlEncodeAndWriteUtf8(value, _htmlEncoder); } /// @@ -177,6 +180,7 @@ protected void Write(ReadOnlySpan value) { _pipeWriter?.HtmlEncodeAndWrite(value, _htmlEncoder); _textWriter?.HtmlEncodeAndWrite(value, _htmlEncoder); + _bufferWriter?.HtmlEncodeAndWrite(value, _htmlEncoder); } } @@ -204,6 +208,7 @@ protected HtmlString WriteBool(bool? value) { _pipeWriter?.Write(value.Value); _textWriter?.Write(value.Value); + _bufferWriter?.Write(value.Value); } return HtmlString.Empty; } @@ -224,6 +229,7 @@ protected HtmlString WriteSpanFormattable(T? formattable, ReadOnlySpan var htmlEncoder = htmlEncode ? _htmlEncoder : NullHtmlEncoder.Default; _pipeWriter?.HtmlEncodeAndWriteSpanFormattable(formattable, htmlEncoder, format, formatProvider); _textWriter?.HtmlEncodeAndWriteSpanFormattable(formattable, htmlEncoder, format, formatProvider); + _bufferWriter?.HtmlEncodeAndWriteSpanFormattable(formattable, htmlEncoder, format, formatProvider); } return HtmlString.Empty; @@ -245,6 +251,7 @@ protected HtmlString WriteUtf8SpanFormattable(T? formattable, ReadOnlySpan(T htmlContent) { if (_pipeWriter is not null) { - _utf8BufferTextWriter ??= Utf8PipeTextWriter.Get(_pipeWriter); - htmlContent.WriteTo(_utf8BufferTextWriter, _htmlEncoder); + _utf8PipeTextWriter ??= Utf8PipeWriterTextWriter.Get(_pipeWriter); + htmlContent.WriteTo(_utf8PipeTextWriter, _htmlEncoder); } - if (_textWriter is not null) + else if (_textWriter is not null) { htmlContent.WriteTo(_textWriter, _htmlEncoder); } + else if (_bufferWriter is not null) + { + _utf8BufferTextWriter ??= Utf8BufferTextWriter.Get(_bufferWriter); + htmlContent.WriteTo(_utf8BufferTextWriter, _htmlEncoder); + } } return HtmlString.Empty; @@ -285,6 +297,7 @@ protected HtmlString WriteHtml(HtmlString htmlString) { _pipeWriter?.WriteHtml(htmlString.Value); _textWriter?.Write(htmlString.Value); + _bufferWriter?.WriteHtml(htmlString.Value); } return HtmlString.Empty; @@ -305,6 +318,7 @@ protected HtmlString WriteHtml(string? htmlString) { _pipeWriter?.WriteHtml(htmlString.AsSpan()); _textWriter?.Write(htmlString); + _bufferWriter?.WriteHtml(htmlString.AsSpan()); } return HtmlString.Empty; diff --git a/src/RazorSlices/RazorSlice.cs b/src/RazorSlices/RazorSlice.cs index ad3c804..15e15cc 100644 --- a/src/RazorSlices/RazorSlice.cs +++ b/src/RazorSlices/RazorSlice.cs @@ -1,4 +1,5 @@ -using System.ComponentModel; +using System.Buffers; +using System.ComponentModel; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.IO.Pipelines; @@ -24,7 +25,10 @@ public abstract partial class RazorSlice : IDisposable private HtmlEncoder _htmlEncoder = HtmlEncoder.Default; private PipeWriter? _pipeWriter; private TextWriter? _textWriter; - private Utf8PipeTextWriter? _utf8BufferTextWriter; + private IBufferWriter? _bufferWriter; + private Utf8PipeWriterTextWriter? _utf8PipeTextWriter; + private Utf8BufferTextWriter? _utf8BufferTextWriter; + private Func>? _outputFlush; private bool _disposed; /// @@ -63,6 +67,7 @@ public IServiceProvider? ServiceProvider /// /// This method should not be called directly. Call /// or + /// or /// instead to render the template. /// /// A representing the execution of the template. @@ -141,6 +146,41 @@ public ValueTask RenderAsync(TextWriter textWriter, HtmlEncoder? htmlEncoder = n return RenderToTextWriterAsync(textWriter, htmlEncoder, cancellationToken); } + + /// + /// Renders the template to the specified . + /// + /// The to render the template to. + /// An optional delegate that flushes the . + /// An optional instance to use when rendering the template. If none is specified, will be used. + /// A token to monitor for cancellation requests. + /// A representing the rendering of the template. + public ValueTask RenderAsync(IBufferWriter bufferWriter, Func? flushAsync = null, HtmlEncoder? htmlEncoder = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(bufferWriter); + + if (flushAsync != null) + { + _outputFlush = (ct) => + { + var flushTask = flushAsync(ct); + + if (flushTask.IsCompletedSuccessfully) + { + return ValueTask.FromResult(_noFlushResult); + } + + return AwaitOutputFlushTask(flushTask); + }; + } + else + { + _outputFlush = null; + } + + return RenderToBufferWriterAsync(bufferWriter, htmlEncoder, cancellationToken); + } + [MemberNotNull(nameof(_pipeWriter))] private ValueTask RenderToPipeWriterAsync(PipeWriter pipeWriter, HtmlEncoder? htmlEncoder, CancellationToken cancellationToken, bool renderLayout = true) { @@ -148,6 +188,7 @@ private ValueTask RenderToPipeWriterAsync(PipeWriter pipeWriter, HtmlEncoder? ht _pipeWriter = pipeWriter; _textWriter = null; + _bufferWriter = null; _htmlEncoder = htmlEncoder ?? _htmlEncoder; CancellationToken = cancellationToken; @@ -176,6 +217,7 @@ private ValueTask RenderToTextWriterAsync(TextWriter textWriter, HtmlEncoder? ht _pipeWriter = null; _textWriter = textWriter; + _bufferWriter = null; _htmlEncoder = htmlEncoder ?? _htmlEncoder; CancellationToken = cancellationToken; @@ -199,12 +241,42 @@ private ValueTask RenderToTextWriterAsync(TextWriter textWriter, HtmlEncoder? ht return ValueTask.CompletedTask; } + [MemberNotNull(nameof(_bufferWriter))] + private ValueTask RenderToBufferWriterAsync(IBufferWriter bufferWriter, HtmlEncoder? htmlEncoder, CancellationToken cancellationToken, bool renderLayout = true) + { + _pipeWriter = null; + _textWriter = null; + _bufferWriter = bufferWriter; + _htmlEncoder = htmlEncoder ?? _htmlEncoder; + CancellationToken = cancellationToken; + + if (renderLayout && this is IUsesLayout useLayout) + { + return RenderViaLayout(RenderToBufferWriterAsync, useLayout, bufferWriter, htmlEncoder, cancellationToken); + } + + var executeTask = ExecuteAsyncImpl(); + + if (!executeTask.HandleSynchronousCompletion()) + { + // Go async + return AwaitExecuteTaskFlushAndDispose(this, executeTask); + } + + Dispose(); + + return AutoFlush().GetAsValueTask(); + } + private static ValueTask RenderToPipeWriterAsync(RazorSlice razorSlice, PipeWriter pipeWriter, HtmlEncoder? htmlEncoder, CancellationToken cancellationToken) => razorSlice.RenderToPipeWriterAsync(pipeWriter, htmlEncoder, cancellationToken); private static ValueTask RenderToTextWriterAsync(RazorSlice razorSlice, TextWriter textWriter, HtmlEncoder? htmlEncoder, CancellationToken cancellationToken) => razorSlice.RenderToTextWriterAsync(textWriter, htmlEncoder, cancellationToken); + private static ValueTask RenderToBufferWriterAsync(RazorSlice razorSlice, IBufferWriter bufferWriter, HtmlEncoder? htmlEncoder, CancellationToken cancellationToken) + => razorSlice.RenderToBufferWriterAsync(bufferWriter, htmlEncoder, cancellationToken); + private ValueTask RenderViaLayout(Func render, IUsesLayout usesLayout, TWriter writer, HtmlEncoder? htmlEncoder, CancellationToken cancellationToken) { var layoutSlice = usesLayout.CreateLayoutImpl(); @@ -244,12 +316,19 @@ private ValueTask AutoFlush() return _pipeWriter.FlushAsync(CancellationToken); } + if (_outputFlush is not null) + { + Debug.WriteLine($"Auto-flushing slice of type '{GetType().Name}' to a BufferWriter"); + return _outputFlush(CancellationToken); + } + return ValueTask.FromResult(_noFlushResult); } - private static async ValueTask AwaitOutputFlushTask(Task flushTask) + private static async ValueTask AwaitOutputFlushTask(ValueTask flushTask) { await flushTask; + return _noFlushResult; } private static async ValueTask AwaitExecuteTaskFlushAndDispose(RazorSlice slice, Task executeTask) @@ -287,6 +366,17 @@ protected ValueTask FlushAsync() return ValueTask.FromResult(HtmlString.Empty); } + else if (_bufferWriter is not null && _outputFlush is not null) + { + var bufferWriterFlushTask = _outputFlush(CancellationToken); + if (!bufferWriterFlushTask.IsCompletedSuccessfully) + { + // Go async + return AwaitBufferWriterFlushAsyncTask(bufferWriterFlushTask); + } + + return ValueTask.FromResult(HtmlString.Empty); + } throw new UnreachableException(); } @@ -303,12 +393,18 @@ private static async ValueTask AwaitTextWriterFlushAsyncTask(Task fl return HtmlString.Empty; } + private static async ValueTask AwaitBufferWriterFlushAsyncTask(ValueTask flushTask) + { + await flushTask; + return HtmlString.Empty; + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private void ReturnPooledObjects() { - if (_utf8BufferTextWriter is not null) + if (_utf8PipeTextWriter is not null) { - Utf8PipeTextWriter.Return(_utf8BufferTextWriter); + Utf8PipeWriterTextWriter.Return(_utf8PipeTextWriter); } } diff --git a/src/RazorSlices/RazorSlices.csproj b/src/RazorSlices/RazorSlices.csproj index 5589cf6..34fe5ba 100644 --- a/src/RazorSlices/RazorSlices.csproj +++ b/src/RazorSlices/RazorSlices.csproj @@ -45,6 +45,11 @@ True RazorSlice.Formattables.tt + + True + True + Utf8TextWriterTemplate.tt + @@ -52,6 +57,10 @@ TextTemplatingFileGenerator RazorSlice.Formattables.cs + + TextTemplatingFileGenerator + Utf8TextWriterTemplate.cs + diff --git a/src/RazorSlices/Utf8TextWriterTemplate.cs b/src/RazorSlices/Utf8TextWriterTemplate.cs new file mode 100644 index 0000000..928ec1e --- /dev/null +++ b/src/RazorSlices/Utf8TextWriterTemplate.cs @@ -0,0 +1,363 @@ +// DO NOT EDIT +// THIS FILE IS GENERATED BY Utf8TextWriterTemplate.tt + +using System.Buffers; +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Unicode; + +namespace Microsoft.AspNetCore.Internal; + + +// Adapted from https://github.com/dotnet/aspnetcore/blob/main/src/SignalR/common/Shared/Utf8BufferTextWriter.cs +internal sealed class Utf8BufferTextWriter : TextWriter +{ + private static readonly UTF8Encoding _utf8NoBom = new(encoderShouldEmitUTF8Identifier: false); + private const int MaximumBytesPerUtf8Char = 4; + + [ThreadStatic] + private static Utf8BufferTextWriter? _cachedInstance; + + private IBufferWriter? _bufferWriter; + private Memory _memory; + private int _memoryUsed; + +#if DEBUG + private bool _inUse; +#endif + + public override Encoding Encoding => _utf8NoBom; + + public static Utf8BufferTextWriter Get(IBufferWriter bufferWriter) + { + var writer = _cachedInstance; + writer ??= new Utf8BufferTextWriter(); + + // Taken off the thread static + _cachedInstance = null; +#if DEBUG + if (writer._inUse) + { + throw new InvalidOperationException("The writer wasn't returned!"); + } + + writer._inUse = true; +#endif + writer.SetWriter(bufferWriter); + return writer; + } + + public static void Return(Utf8BufferTextWriter writer) + { + _cachedInstance = writer; + + writer._memory = Memory.Empty; + writer._memoryUsed = 0; + writer._bufferWriter = null; + +#if DEBUG + writer._inUse = false; +#endif + } + + public void SetWriter(IBufferWriter bufferWriter) + { + _bufferWriter = bufferWriter; + } + + public override void Write(char[] buffer, int index, int count) + { + WriteInternal(buffer.AsSpan(index, count)); + } + + public override void Write(char[]? buffer) + { + if (buffer is not null) + { + WriteInternal(buffer); + } + } + + public override void Write(char value) + { + if (value <= 127) + { + EnsureBuffer(); + + // Only need to set one byte + // Avoid Memory.Slice overhead for perf + _memory.Span[_memoryUsed] = (byte)value; + _memoryUsed++; + } + else + { + WriteMultiByteChar(value); + } + } + + private void WriteMultiByteChar(char value) + { + var destination = GetBuffer(); + + // Json.NET only writes ASCII characters by themselves, e.g. {}[], etc + // this should be an exceptional case + +#if NET7_0_OR_GREATER + Utf8.FromUtf16(new ReadOnlySpan(ref value), destination, out var charsUsed, out var bytesUsed); +#else + ReadOnlySpan valueArray = stackalloc [] { value }; + Utf8.FromUtf16(valueArray, destination, out var charsUsed, out var bytesUsed); +#endif + + Debug.Assert(charsUsed == 1); + + _memoryUsed += bytesUsed; + } + + public override void Write(string? value) + { + if (value is not null) + { + WriteInternal(value.AsSpan()); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private Span GetBuffer() + { + EnsureBuffer(); + + return _memory.Span[_memoryUsed.._memory.Length]; + } + + private void EnsureBuffer() + { + // We need at least enough bytes to encode a single UTF-8 character, or Encoder.Convert will throw. + // Normally, if there isn't enough space to write every character of a char buffer, Encoder.Convert just + // writes what it can. However, if it can't even write a single character, it throws. So if the buffer has only + // 2 bytes left and the next character to write is 3 bytes in UTF-8, an exception is thrown. + var remaining = _memory.Length - _memoryUsed; + if (remaining < MaximumBytesPerUtf8Char) + { + // Used up the memory from the buffer writer so advance and get more + if (_memoryUsed > 0) + { + _bufferWriter!.Advance(_memoryUsed); + } + + _memory = _bufferWriter!.GetMemory(MaximumBytesPerUtf8Char); + _memoryUsed = 0; + } + } + + private void WriteInternal(ReadOnlySpan buffer) + { + while (buffer.Length > 0) + { + // The destination byte array might not be large enough so multiple writes are sometimes required + var destination = GetBuffer(); + + Utf8.FromUtf16(buffer, destination, out var charsUsed, out var bytesUsed); + + buffer = buffer[charsUsed..]; + _memoryUsed += bytesUsed; + } + } + + public override void Flush() + { + if (_memoryUsed > 0) + { + _bufferWriter!.Advance(_memoryUsed); + _memory = _memory[_memoryUsed..]; + _memoryUsed = 0; + } + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + + if (disposing) + { + Flush(); + } + } +} + +// Adapted from https://github.com/dotnet/aspnetcore/blob/main/src/SignalR/common/Shared/Utf8BufferTextWriter.cs +internal sealed class Utf8PipeWriterTextWriter : TextWriter +{ + private static readonly UTF8Encoding _utf8NoBom = new(encoderShouldEmitUTF8Identifier: false); + private const int MaximumBytesPerUtf8Char = 4; + + [ThreadStatic] + private static Utf8PipeWriterTextWriter? _cachedInstance; + + private IBufferWriter? _bufferWriter; + private Memory _memory; + private int _memoryUsed; + +#if DEBUG + private bool _inUse; +#endif + + public override Encoding Encoding => _utf8NoBom; + + public static Utf8PipeWriterTextWriter Get(IBufferWriter bufferWriter) + { + var writer = _cachedInstance; + writer ??= new Utf8PipeWriterTextWriter(); + + // Taken off the thread static + _cachedInstance = null; +#if DEBUG + if (writer._inUse) + { + throw new InvalidOperationException("The writer wasn't returned!"); + } + + writer._inUse = true; +#endif + writer.SetWriter(bufferWriter); + return writer; + } + + public static void Return(Utf8PipeWriterTextWriter writer) + { + _cachedInstance = writer; + + writer._memory = Memory.Empty; + writer._memoryUsed = 0; + writer._bufferWriter = null; + +#if DEBUG + writer._inUse = false; +#endif + } + + public void SetWriter(IBufferWriter bufferWriter) + { + _bufferWriter = bufferWriter; + } + + public override void Write(char[] buffer, int index, int count) + { + WriteInternal(buffer.AsSpan(index, count)); + } + + public override void Write(char[]? buffer) + { + if (buffer is not null) + { + WriteInternal(buffer); + } + } + + public override void Write(char value) + { + if (value <= 127) + { + EnsureBuffer(); + + // Only need to set one byte + // Avoid Memory.Slice overhead for perf + _memory.Span[_memoryUsed] = (byte)value; + _memoryUsed++; + } + else + { + WriteMultiByteChar(value); + } + } + + private void WriteMultiByteChar(char value) + { + var destination = GetBuffer(); + + // Json.NET only writes ASCII characters by themselves, e.g. {}[], etc + // this should be an exceptional case + +#if NET7_0_OR_GREATER + Utf8.FromUtf16(new ReadOnlySpan(ref value), destination, out var charsUsed, out var bytesUsed); +#else + ReadOnlySpan valueArray = stackalloc [] { value }; + Utf8.FromUtf16(valueArray, destination, out var charsUsed, out var bytesUsed); +#endif + + Debug.Assert(charsUsed == 1); + + _memoryUsed += bytesUsed; + } + + public override void Write(string? value) + { + if (value is not null) + { + WriteInternal(value.AsSpan()); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private Span GetBuffer() + { + EnsureBuffer(); + + return _memory.Span[_memoryUsed.._memory.Length]; + } + + private void EnsureBuffer() + { + // We need at least enough bytes to encode a single UTF-8 character, or Encoder.Convert will throw. + // Normally, if there isn't enough space to write every character of a char buffer, Encoder.Convert just + // writes what it can. However, if it can't even write a single character, it throws. So if the buffer has only + // 2 bytes left and the next character to write is 3 bytes in UTF-8, an exception is thrown. + var remaining = _memory.Length - _memoryUsed; + if (remaining < MaximumBytesPerUtf8Char) + { + // Used up the memory from the buffer writer so advance and get more + if (_memoryUsed > 0) + { + _bufferWriter!.Advance(_memoryUsed); + } + + _memory = _bufferWriter!.GetMemory(MaximumBytesPerUtf8Char); + _memoryUsed = 0; + } + } + + private void WriteInternal(ReadOnlySpan buffer) + { + while (buffer.Length > 0) + { + // The destination byte array might not be large enough so multiple writes are sometimes required + var destination = GetBuffer(); + + Utf8.FromUtf16(buffer, destination, out var charsUsed, out var bytesUsed); + + buffer = buffer[charsUsed..]; + _memoryUsed += bytesUsed; + } + } + + public override void Flush() + { + if (_memoryUsed > 0) + { + _bufferWriter!.Advance(_memoryUsed); + _memory = _memory[_memoryUsed..]; + _memoryUsed = 0; + } + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + + if (disposing) + { + Flush(); + } + } +} diff --git a/src/RazorSlices/Utf8PipeTextWriter.cs b/src/RazorSlices/Utf8TextWriterTemplate.tt similarity index 77% rename from src/RazorSlices/Utf8PipeTextWriter.cs rename to src/RazorSlices/Utf8TextWriterTemplate.tt index 4280582..8d41749 100644 --- a/src/RazorSlices/Utf8PipeTextWriter.cs +++ b/src/RazorSlices/Utf8TextWriterTemplate.tt @@ -1,22 +1,29 @@ -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.IO.Pipelines; +<#@ template debug="false" hostspecific="false" language="C#" #> +<#@ assembly name="System.Core" #> +<#@ output extension=".cs" #> +// DO NOT EDIT +// THIS FILE IS GENERATED BY Utf8TextWriterTemplate.tt + +using System.Buffers; +using System.Diagnostics; using System.Runtime.CompilerServices; using System.Text; using System.Text.Unicode; namespace Microsoft.AspNetCore.Internal; +<# foreach (var type in new string[] { "Utf8BufferTextWriter", "Utf8PipeWriterTextWriter" }) { #> + // Adapted from https://github.com/dotnet/aspnetcore/blob/main/src/SignalR/common/Shared/Utf8BufferTextWriter.cs -internal sealed class Utf8PipeTextWriter : TextWriter +internal sealed class <#= type #> : TextWriter { private static readonly UTF8Encoding _utf8NoBom = new(encoderShouldEmitUTF8Identifier: false); private const int MaximumBytesPerUtf8Char = 4; [ThreadStatic] - private static Utf8PipeTextWriter? _cachedInstance; + private static <#= type #>? _cachedInstance; - private PipeWriter? _pipeWriter; + private IBufferWriter? _bufferWriter; private Memory _memory; private int _memoryUsed; @@ -26,10 +33,10 @@ internal sealed class Utf8PipeTextWriter : TextWriter public override Encoding Encoding => _utf8NoBom; - public static Utf8PipeTextWriter Get(PipeWriter pipeWriter) + public static <#= type #> Get(IBufferWriter bufferWriter) { var writer = _cachedInstance; - writer ??= new Utf8PipeTextWriter(); + writer ??= new <#= type #>(); // Taken off the thread static _cachedInstance = null; @@ -41,27 +48,26 @@ public static Utf8PipeTextWriter Get(PipeWriter pipeWriter) writer._inUse = true; #endif - writer.SetWriter(pipeWriter); + writer.SetWriter(bufferWriter); return writer; } - public static void Return(Utf8PipeTextWriter writer) + public static void Return(<#= type #> writer) { _cachedInstance = writer; writer._memory = Memory.Empty; writer._memoryUsed = 0; - writer._pipeWriter = null; + writer._bufferWriter = null; #if DEBUG writer._inUse = false; #endif } - [MemberNotNull(nameof(_pipeWriter))] - public void SetWriter(PipeWriter pipeWriter) + public void SetWriter(IBufferWriter bufferWriter) { - _pipeWriter = pipeWriter; + _bufferWriter = bufferWriter; } public override void Write(char[] buffer, int index, int count) @@ -100,7 +106,13 @@ private void WriteMultiByteChar(char value) // Json.NET only writes ASCII characters by themselves, e.g. {}[], etc // this should be an exceptional case + +#if NET7_0_OR_GREATER Utf8.FromUtf16(new ReadOnlySpan(ref value), destination, out var charsUsed, out var bytesUsed); +#else + ReadOnlySpan valueArray = stackalloc [] { value }; + Utf8.FromUtf16(valueArray, destination, out var charsUsed, out var bytesUsed); +#endif Debug.Assert(charsUsed == 1); @@ -135,10 +147,10 @@ private void EnsureBuffer() // Used up the memory from the buffer writer so advance and get more if (_memoryUsed > 0) { - _pipeWriter!.Advance(_memoryUsed); + _bufferWriter!.Advance(_memoryUsed); } - _memory = _pipeWriter!.GetMemory(MaximumBytesPerUtf8Char); + _memory = _bufferWriter!.GetMemory(MaximumBytesPerUtf8Char); _memoryUsed = 0; } } @@ -161,7 +173,7 @@ public override void Flush() { if (_memoryUsed > 0) { - _pipeWriter!.Advance(_memoryUsed); + _bufferWriter!.Advance(_memoryUsed); _memory = _memory[_memoryUsed..]; _memoryUsed = 0; } @@ -177,3 +189,4 @@ protected override void Dispose(bool disposing) } } } +<# } #> \ No newline at end of file diff --git a/tests/RazorSlices.Tests/BufferWriterHtmlWritingTests.cs b/tests/RazorSlices.Tests/BufferWriterHtmlWritingTests.cs index 11d31b4..6f7038b 100644 --- a/tests/RazorSlices.Tests/BufferWriterHtmlWritingTests.cs +++ b/tests/RazorSlices.Tests/BufferWriterHtmlWritingTests.cs @@ -1,6 +1,5 @@ using System.Buffers; using System.Diagnostics; -using System.IO.Pipelines; using System.Text; using System.Text.Encodings.Web; using Microsoft.AspNetCore.Razor.TagHelpers; @@ -12,46 +11,40 @@ public class BufferWriterHtmlWritingTests private static readonly TimeSpan _timeout = Debugger.IsAttached ? Timeout.InfiniteTimeSpan : TimeSpan.FromMilliseconds(5000); [Fact] - public async ValueTask HtmlEncodeAndWrite_DoesNotEncodeTextWhenPassedNullEncoder() + public void HtmlEncodeAndWrite_DoesNotEncodeTextWhenPassedNullEncoder() { - var bufferStream = new MemoryStream(512); - var pipeWriter = PipeWriter.Create(bufferStream); + var bufferWriter = new ArrayBufferWriter(); var text = "Hello, World!"; - pipeWriter.HtmlEncodeAndWrite(text.AsSpan(), NullHtmlEncoder.Default); - await pipeWriter.FlushAsync(); + bufferWriter.HtmlEncodeAndWrite(text.AsSpan(), NullHtmlEncoder.Default); - var writtenText = Encoding.UTF8.GetString(bufferStream.ToArray(), 0, (int)bufferStream.Length); + var writtenText = Encoding.UTF8.GetString(bufferWriter.WrittenSpan); Assert.Equal(text, writtenText); } [Fact] - public async ValueTask HtmlEncodeAndWrite_DoesNotEncodeTextThatRequiresNoEncoding() + public void HtmlEncodeAndWrite_DoesNotEncodeTextThatRequiresNoEncoding() { - var bufferStream = new MemoryStream(512); - var pipeWriter = PipeWriter.Create(bufferStream); + var bufferWriter = new ArrayBufferWriter(); var text = "Hello, World!"; - pipeWriter.HtmlEncodeAndWrite(text.AsSpan(), HtmlEncoder.Default); - await pipeWriter.FlushAsync(); + bufferWriter.HtmlEncodeAndWrite(text.AsSpan(), HtmlEncoder.Default); - var writtenText = Encoding.UTF8.GetString(bufferStream.ToArray(), 0, (int)bufferStream.Length); + var writtenText = Encoding.UTF8.GetString(bufferWriter.WrittenSpan); Assert.Equal(text, writtenText); } [Fact] - public async ValueTask HtmlEncodeAndWrite_EncodesSimpleText() + public void HtmlEncodeAndWrite_EncodesSimpleText() { - var bufferStream = new MemoryStream(512); - var pipeWriter = PipeWriter.Create(bufferStream); + var bufferWriter = new ArrayBufferWriter(); var text = "Hello, !"; - pipeWriter.HtmlEncodeAndWrite(text.AsSpan(), HtmlEncoder.Default); - await pipeWriter.FlushAsync(); + bufferWriter.HtmlEncodeAndWrite(text.AsSpan(), HtmlEncoder.Default); - var writtenText = Encoding.UTF8.GetString(bufferStream.ToArray(), 0, (int)bufferStream.Length); + var writtenText = Encoding.UTF8.GetString(bufferWriter.WrittenSpan); Assert.Equal("Hello, <World>!", writtenText); } @@ -59,22 +52,19 @@ public async ValueTask HtmlEncodeAndWrite_EncodesSimpleText() [Fact] public async Task HtmlEncodeAndWrite_EncodesTextThatIsLessThanSmallEncodeBuffer() { - var bufferStream = new MemoryStream(512); - var pipeWriter = PipeWriter.Create(bufferStream); + var bufferWriter = new ArrayBufferWriter(); // Create a string with no encodable chars that's half the small threshold then add a small string that requires encoding var text = new string('a', BufferSizes.SmallTextWriteCharSize / 2); text += ""; - var encodeTask = Task.Run(() => pipeWriter.HtmlEncodeAndWrite(text.AsSpan(), HtmlEncoder.Default)); + var encodeTask = Task.Run(() => bufferWriter.HtmlEncodeAndWrite(text.AsSpan(), HtmlEncoder.Default)); var completedTask = await Task.WhenAny(encodeTask, Task.Delay(_timeout)); Assert.Equal(encodeTask, completedTask); - await pipeWriter.FlushAsync(); - - var writtenText = Encoding.UTF8.GetString(bufferStream.ToArray(), 0, (int)bufferStream.Length); + var writtenText = Encoding.UTF8.GetString(bufferWriter.WrittenSpan); var expected = text.Replace("<", "<").Replace(">", ">"); Assert.Equal(expected, writtenText); @@ -84,8 +74,7 @@ public async Task HtmlEncodeAndWrite_EncodesTextThatIsLessThanSmallEncodeBuffer( [Fact] public async Task HtmlEncodeAndWrite_EncodesTextThatIsLessThanSmallEncodeBufferAndButRequiresMultiplePasses() { - var bufferStream = new MemoryStream(512); - var pipeWriter = PipeWriter.Create(bufferStream); + var bufferWriter = new ArrayBufferWriter(); // Create a string with no encodable chars that's 5 chars smaller than the threshold then add a two chars that require encoding // This should result in the first encode phase being successful as the encoded chars will fit in the small buffer but there will @@ -93,15 +82,13 @@ public async Task HtmlEncodeAndWrite_EncodesTextThatIsLessThanSmallEncodeBufferA var text = new string('a', BufferSizes.SmallTextWriteCharSize - 5); text += "<>"; - var encodeTask = Task.Run(() => pipeWriter.HtmlEncodeAndWrite(text.AsSpan(), HtmlEncoder.Default)); + var encodeTask = Task.Run(() => bufferWriter.HtmlEncodeAndWrite(text.AsSpan(), HtmlEncoder.Default)); var completedTask = await Task.WhenAny(encodeTask, Task.Delay(_timeout)); Assert.Equal(encodeTask, completedTask); - await pipeWriter.FlushAsync(); - - var writtenText = Encoding.UTF8.GetString(bufferStream.ToArray(), 0, (int)bufferStream.Length); + var writtenText = Encoding.UTF8.GetString(bufferWriter.WrittenSpan); var expected = text.Replace("<", "<").Replace(">", ">"); Assert.Equal(expected, writtenText); @@ -109,47 +96,20 @@ public async Task HtmlEncodeAndWrite_EncodesTextThatIsLessThanSmallEncodeBufferA [Fact] public async Task HtmlEncodeAndWrite_EncodesTextThatIsLargerThanEncodeBuffer() { - var bufferStream = new MemoryStream(512); - var pipeWriter = PipeWriter.Create(bufferStream); + var bufferWriter = new ArrayBufferWriter(); // Every character in this text must be HTML encoded so it will definitely exceed the max buffer size var text = new string('<', BufferSizes.MaxBufferSize); - var encodeTask = Task.Run(() => pipeWriter.HtmlEncodeAndWrite(text.AsSpan(), HtmlEncoder.Default)); + var encodeTask = Task.Run(() => bufferWriter.HtmlEncodeAndWrite(text.AsSpan(), HtmlEncoder.Default)); var completedTask = await Task.WhenAny(encodeTask, Task.Delay(_timeout)); - Assert.Equal(encodeTask, completedTask); + Assert.Equal(encodeTask, completedTask); - await pipeWriter.FlushAsync(); - - var writtenText = Encoding.UTF8.GetString(bufferStream.ToArray(), 0, (int)bufferStream.Length); + var writtenText = Encoding.UTF8.GetString(bufferWriter.WrittenSpan); var expected = text.Replace("<", "<"); Assert.Equal(expected, writtenText); } - - [Fact] - public async Task HtmlEncodeAndWrite_EncodesTextWhenRemainingBufferIsTooSmallToEncodeAnyRemainingChars() - { - var bufferStream = new MemoryStream(512); - var pipeWriter = PipeWriter.Create(bufferStream); - - // This text can't be fully encoded in one cycle and results in the buffer being too small to encode even one of the remaining chars into on the second cycle - // so specifically exercises the case where the buffer is too small to encode any of the remaining chars into and thus must write out the chars encoded thus - // far before resetting the buffer and encoding the remaining chars - var text = "{'antiForgery':'CfDJ8PXqQxmMPkhOiAitfXYwz3Qx8ObgK9ND-yZcc0Wf5Vtyo8pylpAtos8zwLNhJ-T3gJ4m2iX96lbH15gV0LIdcEBHU5xf3U95L_rlojOvp-0XIvZ8HI3CWIr6xUIKGhDpKaasa9hMAzjUZuFjYVdDkmE','formFieldName':'__RequestVerificationToken','antiForgery':'CfDJ8PXqQxmMPkhOiAitfXYwz3Qx8ObgK9ND-yZcc0Wf5Vtyo8pylpAtos8zwLNhJ-T3gJ4m2iX96lbH15gV0LIdcEBHU5xf3U95L_rlojOvp-0XIvZ8HI3CWIr6xUIKGhDpKaasa9hMAzjUZuFjYVdDkmE','formFieldName':'__RequestVerificationToken'}"; - - var encodeTask = Task.Run(() => pipeWriter.HtmlEncodeAndWrite(text.AsSpan(), HtmlEncoder.Default)); - - var completedTask = await Task.WhenAny(encodeTask, Task.Delay(_timeout)); - - Assert.Equal(encodeTask, completedTask); - - await pipeWriter.FlushAsync(); - - var writtenText = Encoding.UTF8.GetString(bufferStream.ToArray(), 0, (int)bufferStream.Length); - - Assert.Equal("{'antiForgery':'CfDJ8PXqQxmMPkhOiAitfXYwz3Qx8ObgK9ND-yZcc0Wf5Vtyo8pylpAtos8zwLNhJ-T3gJ4m2iX96lbH15gV0LIdcEBHU5xf3U95L_rlojOvp-0XIvZ8HI3CWIr6xUIKGhDpKaasa9hMAzjUZuFjYVdDkmE','formFieldName':'__RequestVerificationToken','antiForgery':'CfDJ8PXqQxmMPkhOiAitfXYwz3Qx8ObgK9ND-yZcc0Wf5Vtyo8pylpAtos8zwLNhJ-T3gJ4m2iX96lbH15gV0LIdcEBHU5xf3U95L_rlojOvp-0XIvZ8HI3CWIr6xUIKGhDpKaasa9hMAzjUZuFjYVdDkmE','formFieldName':'__RequestVerificationToken'}", writtenText); - } } \ No newline at end of file diff --git a/tests/RazorSlices.Tests/PipeWriterHtmlWritingTests.cs b/tests/RazorSlices.Tests/PipeWriterHtmlWritingTests.cs new file mode 100644 index 0000000..d770d22 --- /dev/null +++ b/tests/RazorSlices.Tests/PipeWriterHtmlWritingTests.cs @@ -0,0 +1,155 @@ +using System.Buffers; +using System.Diagnostics; +using System.IO.Pipelines; +using System.Text; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Razor.TagHelpers; + +namespace RazorSlices.Tests; + +public class PipeWriterHtmlWritingTests +{ + private static readonly TimeSpan _timeout = Debugger.IsAttached ? Timeout.InfiniteTimeSpan : TimeSpan.FromMilliseconds(5000); + + [Fact] + public async ValueTask HtmlEncodeAndWrite_DoesNotEncodeTextWhenPassedNullEncoder() + { + var bufferStream = new MemoryStream(512); + var pipeWriter = PipeWriter.Create(bufferStream); + + var text = "Hello, World!"; + pipeWriter.HtmlEncodeAndWrite(text.AsSpan(), NullHtmlEncoder.Default); + await pipeWriter.FlushAsync(); + + var writtenText = Encoding.UTF8.GetString(bufferStream.ToArray(), 0, (int)bufferStream.Length); + + Assert.Equal(text, writtenText); + } + + [Fact] + public async ValueTask HtmlEncodeAndWrite_DoesNotEncodeTextThatRequiresNoEncoding() + { + var bufferStream = new MemoryStream(512); + var pipeWriter = PipeWriter.Create(bufferStream); + + var text = "Hello, World!"; + pipeWriter.HtmlEncodeAndWrite(text.AsSpan(), HtmlEncoder.Default); + await pipeWriter.FlushAsync(); + + var writtenText = Encoding.UTF8.GetString(bufferStream.ToArray(), 0, (int)bufferStream.Length); + + Assert.Equal(text, writtenText); + } + + [Fact] + public async ValueTask HtmlEncodeAndWrite_EncodesSimpleText() + { + var bufferStream = new MemoryStream(512); + var pipeWriter = PipeWriter.Create(bufferStream); + + var text = "Hello, !"; + pipeWriter.HtmlEncodeAndWrite(text.AsSpan(), HtmlEncoder.Default); + await pipeWriter.FlushAsync(); + + var writtenText = Encoding.UTF8.GetString(bufferStream.ToArray(), 0, (int)bufferStream.Length); + + Assert.Equal("Hello, <World>!", writtenText); + } + + [Fact] + public async Task HtmlEncodeAndWrite_EncodesTextThatIsLessThanSmallEncodeBuffer() + { + var bufferStream = new MemoryStream(512); + var pipeWriter = PipeWriter.Create(bufferStream); + + // Create a string with no encodable chars that's half the small threshold then add a small string that requires encoding + var text = new string('a', BufferSizes.SmallTextWriteCharSize / 2); + text += ""; + + var encodeTask = Task.Run(() => pipeWriter.HtmlEncodeAndWrite(text.AsSpan(), HtmlEncoder.Default)); + + var completedTask = await Task.WhenAny(encodeTask, Task.Delay(_timeout)); + + Assert.Equal(encodeTask, completedTask); + + await pipeWriter.FlushAsync(); + + var writtenText = Encoding.UTF8.GetString(bufferStream.ToArray(), 0, (int)bufferStream.Length); + + var expected = text.Replace("<", "<").Replace(">", ">"); + Assert.Equal(expected, writtenText); + } + + + [Fact] + public async Task HtmlEncodeAndWrite_EncodesTextThatIsLessThanSmallEncodeBufferAndButRequiresMultiplePasses() + { + var bufferStream = new MemoryStream(512); + var pipeWriter = PipeWriter.Create(bufferStream); + + // Create a string with no encodable chars that's 5 chars smaller than the threshold then add a two chars that require encoding + // This should result in the first encode phase being successful as the encoded chars will fit in the small buffer but there will + // not be enough space left in the buffer to encode the remaining char, forcing a buffer reset or growth. + var text = new string('a', BufferSizes.SmallTextWriteCharSize - 5); + text += "<>"; + + var encodeTask = Task.Run(() => pipeWriter.HtmlEncodeAndWrite(text.AsSpan(), HtmlEncoder.Default)); + + var completedTask = await Task.WhenAny(encodeTask, Task.Delay(_timeout)); + + Assert.Equal(encodeTask, completedTask); + + await pipeWriter.FlushAsync(); + + var writtenText = Encoding.UTF8.GetString(bufferStream.ToArray(), 0, (int)bufferStream.Length); + + var expected = text.Replace("<", "<").Replace(">", ">"); + Assert.Equal(expected, writtenText); + } + [Fact] + public async Task HtmlEncodeAndWrite_EncodesTextThatIsLargerThanEncodeBuffer() + { + var bufferStream = new MemoryStream(512); + var pipeWriter = PipeWriter.Create(bufferStream); + + // Every character in this text must be HTML encoded so it will definitely exceed the max buffer size + var text = new string('<', BufferSizes.MaxBufferSize); + + var encodeTask = Task.Run(() => pipeWriter.HtmlEncodeAndWrite(text.AsSpan(), HtmlEncoder.Default)); + + var completedTask = await Task.WhenAny(encodeTask, Task.Delay(_timeout)); + + Assert.Equal(encodeTask, completedTask); + + await pipeWriter.FlushAsync(); + + var writtenText = Encoding.UTF8.GetString(bufferStream.ToArray(), 0, (int)bufferStream.Length); + + var expected = text.Replace("<", "<"); + Assert.Equal(expected, writtenText); + } + + [Fact] + public async Task HtmlEncodeAndWrite_EncodesTextWhenRemainingBufferIsTooSmallToEncodeAnyRemainingChars() + { + var bufferStream = new MemoryStream(512); + var pipeWriter = PipeWriter.Create(bufferStream); + + // This text can't be fully encoded in one cycle and results in the buffer being too small to encode even one of the remaining chars into on the second cycle + // so specifically exercises the case where the buffer is too small to encode any of the remaining chars into and thus must write out the chars encoded thus + // far before resetting the buffer and encoding the remaining chars + var text = "{'antiForgery':'CfDJ8PXqQxmMPkhOiAitfXYwz3Qx8ObgK9ND-yZcc0Wf5Vtyo8pylpAtos8zwLNhJ-T3gJ4m2iX96lbH15gV0LIdcEBHU5xf3U95L_rlojOvp-0XIvZ8HI3CWIr6xUIKGhDpKaasa9hMAzjUZuFjYVdDkmE','formFieldName':'__RequestVerificationToken','antiForgery':'CfDJ8PXqQxmMPkhOiAitfXYwz3Qx8ObgK9ND-yZcc0Wf5Vtyo8pylpAtos8zwLNhJ-T3gJ4m2iX96lbH15gV0LIdcEBHU5xf3U95L_rlojOvp-0XIvZ8HI3CWIr6xUIKGhDpKaasa9hMAzjUZuFjYVdDkmE','formFieldName':'__RequestVerificationToken'}"; + + var encodeTask = Task.Run(() => pipeWriter.HtmlEncodeAndWrite(text.AsSpan(), HtmlEncoder.Default)); + + var completedTask = await Task.WhenAny(encodeTask, Task.Delay(_timeout)); + + Assert.Equal(encodeTask, completedTask); + + await pipeWriter.FlushAsync(); + + var writtenText = Encoding.UTF8.GetString(bufferStream.ToArray(), 0, (int)bufferStream.Length); + + Assert.Equal("{'antiForgery':'CfDJ8PXqQxmMPkhOiAitfXYwz3Qx8ObgK9ND-yZcc0Wf5Vtyo8pylpAtos8zwLNhJ-T3gJ4m2iX96lbH15gV0LIdcEBHU5xf3U95L_rlojOvp-0XIvZ8HI3CWIr6xUIKGhDpKaasa9hMAzjUZuFjYVdDkmE','formFieldName':'__RequestVerificationToken','antiForgery':'CfDJ8PXqQxmMPkhOiAitfXYwz3Qx8ObgK9ND-yZcc0Wf5Vtyo8pylpAtos8zwLNhJ-T3gJ4m2iX96lbH15gV0LIdcEBHU5xf3U95L_rlojOvp-0XIvZ8HI3CWIr6xUIKGhDpKaasa9hMAzjUZuFjYVdDkmE','formFieldName':'__RequestVerificationToken'}", writtenText); + } +} \ No newline at end of file