diff --git a/Fluid.Tests/Fluid.Tests.csproj b/Fluid.Tests/Fluid.Tests.csproj index c21c7788..196b97f4 100644 --- a/Fluid.Tests/Fluid.Tests.csproj +++ b/Fluid.Tests/Fluid.Tests.csproj @@ -23,6 +23,7 @@ + diff --git a/Fluid.Tests/PipeWriterFluidOutputTests.cs b/Fluid.Tests/PipeWriterFluidOutputTests.cs new file mode 100644 index 00000000..11e32e43 --- /dev/null +++ b/Fluid.Tests/PipeWriterFluidOutputTests.cs @@ -0,0 +1,110 @@ +using System; +using System.Buffers; +using System.IO.Pipelines; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using MinimalApis.LiquidViews; +using Xunit; + +namespace Fluid.Tests +{ + public class PipeWriterFluidOutputTests + { + [Fact] + public async Task WritesUtf8DirectlyToPipe() + { + var pipe = new Pipe(); + await using var output = new PipeWriterFluidOutput(pipe.Writer, bufferSize: 16); + + output.Write("Hello, "); + output.Write("\u4e16\u754c"); + await output.FlushAsync(); + + Assert.Equal("Hello, \u4e16\u754c", await ReadAsync(pipe.Reader)); + } + + [Fact] + public async Task PreservesSurrogatePairsAcrossWrites() + { + var pipe = new Pipe(); + await using var output = new PipeWriterFluidOutput(pipe.Writer, bufferSize: 16); + + output.Write("\ud83d"); + output.Write("\ude80"); + await output.FlushAsync(); + + Assert.Equal("\ud83d\ude80", await ReadAsync(pipe.Reader)); + } + + [Fact] + public async Task EncodesCharactersWrittenThroughBufferWriter() + { + var pipe = new Pipe(); + await using var output = new PipeWriterFluidOutput(pipe.Writer, bufferSize: 2); + + "Fluid".AsSpan().CopyTo(output.GetSpan(5)); + output.Advance(5); + await output.FlushAsync(); + + Assert.Equal("Fluid", await ReadAsync(pipe.Reader)); + } + + [Fact] + public async Task FlushesWhenOutputBufferSizeIsReached() + { + var pipe = new Pipe(); + await using var output = new PipeWriterFluidOutput(pipe.Writer, bufferSize: 4); + + output.Write("content"); + + Assert.Equal("content", await ReadAsync(pipe.Reader)); + } + + [Fact] + public async Task BufferGrowthDoesNotIncreaseFlushThreshold() + { + var pipe = new Pipe(); + await using var output = new PipeWriterFluidOutput(pipe.Writer, bufferSize: 4); + + "data".AsSpan().CopyTo(output.GetSpan(8)); + output.Advance(4); + + Assert.Equal("data", await ReadAsync(pipe.Reader)); + } + + [Fact] + public async Task FlushHonorsCancellation() + { + var pipe = new Pipe(); + using var cancellation = new CancellationTokenSource(); + var output = new PipeWriterFluidOutput(pipe.Writer, bufferSize: 16, cancellation.Token); + output.Write("value"); + cancellation.Cancel(); + + await Assert.ThrowsAsync(() => output.FlushAsync().AsTask()); + await output.DisposeAsync(); + } + + [Fact] + public async Task DisposeSkipsFlushAfterCancellation() + { + var pipe = new Pipe(); + using var cancellation = new CancellationTokenSource(); + var output = new PipeWriterFluidOutput(pipe.Writer, bufferSize: 16, cancellation.Token); + output.Write("value"); + + cancellation.Cancel(); + + await output.DisposeAsync(); + } + + private static async Task ReadAsync(PipeReader reader) + { + var result = await reader.ReadAsync(); + var value = Encoding.UTF8.GetString(result.Buffer.ToArray()); + reader.AdvanceTo(result.Buffer.End); + return value; + } + } +} diff --git a/MinimalApis.LiquidViews/ActionViewResult.cs b/MinimalApis.LiquidViews/ActionViewResult.cs index ecac8872..0d6c9108 100644 --- a/MinimalApis.LiquidViews/ActionViewResult.cs +++ b/MinimalApis.LiquidViews/ActionViewResult.cs @@ -7,7 +7,6 @@ using System.IO; using System.Threading; using System.Threading.Tasks; -using Fluid.Utils; namespace MinimalApis.LiquidViews { @@ -58,22 +57,18 @@ public async Task ExecuteAsync(HttpContext httpContext) httpContext.Response.StatusCode = 200; httpContext.Response.ContentType = ContentType; - await using var sw = new StreamWriter(httpContext.Response.Body); var bufferSize = context.Options.OutputBufferSize; if (bufferSize <= 0) { bufferSize = 16 * 1024; } - await using var output = new TextWriterFluidOutput( - sw, + await using var output = new PipeWriterFluidOutput( + httpContext.Response.BodyWriter, bufferSize, - httpContext.RequestAborted, - leaveOpen: true, - allowSynchronousIO: false); + httpContext.RequestAborted); await fluidViewRenderer.RenderViewAsync(output, viewPath, context); await output.FlushAsync(); - await sw.FlushAsync(httpContext.RequestAborted); } private static async ValueTask LocatePageFromViewLocationsAsync( diff --git a/MinimalApis.LiquidViews/PipeWriterFluidOutput.cs b/MinimalApis.LiquidViews/PipeWriterFluidOutput.cs new file mode 100644 index 00000000..0313ca74 --- /dev/null +++ b/MinimalApis.LiquidViews/PipeWriterFluidOutput.cs @@ -0,0 +1,294 @@ +using Fluid; +using System; +using System.Buffers; +using System.IO.Pipelines; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace MinimalApis.LiquidViews +{ + /// + /// Writes Fluid output as UTF-8 directly to a . + /// + public sealed class PipeWriterFluidOutput : IFluidOutput, IAsyncDisposable + { + private static readonly Encoding Utf8 = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + + private readonly PipeWriter _writer; + private readonly Encoder _encoder; + private readonly CancellationToken _cancellationToken; + private readonly int _flushThreshold; + private char[] _buffer; + private int _bufferCapacity; + private int _index; + private bool _disposed; + private bool _encoderNeedsFlush; + private int _unflushedBytes; + + public PipeWriterFluidOutput( + PipeWriter writer, + int bufferSize, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(writer); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(bufferSize); + + _writer = writer; + _encoder = Utf8.GetEncoder(); + _cancellationToken = cancellationToken; + _flushThreshold = bufferSize; + _buffer = ArrayPool.Shared.Rent(bufferSize); + _bufferCapacity = bufferSize; + } + + public void Advance(int count) + { + ThrowIfDisposed(); + + if ((uint) count > (uint) (_bufferCapacity - _index)) + { + throw new ArgumentOutOfRangeException(nameof(count)); + } + + _index += count; + + if (_index >= _flushThreshold) + { + FlushBuffer(); + } + } + + public Memory GetMemory(int sizeHint = 0) + { + EnsureBuffer(sizeHint); + return _buffer.AsMemory(_index, _bufferCapacity - _index); + } + + public Span GetSpan(int sizeHint = 0) + { + EnsureBuffer(sizeHint); + return _buffer.AsSpan(_index, _bufferCapacity - _index); + } + + public void Write(string value) + { + ThrowIfDisposed(); + + if (!String.IsNullOrEmpty(value)) + { + if (value.Length >= _flushThreshold) + { + FlushBuffer(); + Encode(value.AsSpan(), flush: false); + return; + } + + if (_bufferCapacity - _index < value.Length) + { + FlushBuffer(); + } + + value.AsSpan().CopyTo(_buffer.AsSpan(_index)); + _index += value.Length; + + if (_index >= _flushThreshold) + { + FlushBuffer(); + } + } + } + + public void Write(char[] buffer, int index, int count) + { + ArgumentNullException.ThrowIfNull(buffer); + ThrowIfDisposed(); + + if (count != 0) + { + var source = buffer.AsSpan(index, count); + + if (count >= _flushThreshold) + { + FlushBuffer(); + Encode(source, flush: false); + return; + } + + if (_bufferCapacity - _index < count) + { + FlushBuffer(); + } + + source.CopyTo(_buffer.AsSpan(_index)); + _index += count; + + if (_index >= _flushThreshold) + { + FlushBuffer(); + } + } + } + + public ValueTask FlushAsync() + { + ThrowIfDisposed(); + _cancellationToken.ThrowIfCancellationRequested(); + + if (!NeedsFlush) + { + return default; + } + + FlushBuffer(); + Encode(ReadOnlySpan.Empty, flush: true); + _encoder.Reset(); + + var flush = _writer.FlushAsync(_cancellationToken); + if (flush.IsCompletedSuccessfully) + { + ThrowIfCanceled(flush.Result); + _encoderNeedsFlush = false; + _unflushedBytes = 0; + return default; + } + + return Awaited(flush, this); + + static async ValueTask Awaited(ValueTask flush, PipeWriterFluidOutput output) + { + ThrowIfCanceled(await flush.ConfigureAwait(false), output._cancellationToken); + output._encoderNeedsFlush = false; + output._unflushedBytes = 0; + } + } + + public async ValueTask DisposeAsync() + { + if (_disposed) + { + return; + } + + try + { + if (NeedsFlush && !_cancellationToken.IsCancellationRequested) + { + await FlushAsync().ConfigureAwait(false); + } + } + finally + { + _disposed = true; + var buffer = _buffer; + _buffer = null; + ArrayPool.Shared.Return(buffer); + } + } + + private static void ThrowIfCanceled(FlushResult result, CancellationToken cancellationToken = default) + { + if (result.IsCanceled) + { + throw new OperationCanceledException(cancellationToken); + } + } + + private void Encode(ReadOnlySpan source, bool flush) + { + _cancellationToken.ThrowIfCancellationRequested(); + var encoderNeedsFlush = !flush && !source.IsEmpty && char.IsHighSurrogate(source[source.Length - 1]); + + do + { + var charCount = Math.Min(source.Length, 1024); + var destination = _writer.GetSpan(Utf8.GetMaxByteCount(Math.Max(charCount, 1))); + + _encoder.Convert( + source, + destination, + flush, + out var charsUsed, + out var bytesUsed, + out var completed); + + if (bytesUsed != 0) + { + _writer.Advance(bytesUsed); + _unflushedBytes += bytesUsed; + + if (!flush && _unflushedBytes >= _flushThreshold) + { + FlushPipeSynchronously(); + } + } + + source = source.Slice(charsUsed); + + if (completed) + { + _encoderNeedsFlush = encoderNeedsFlush; + return; + } + } + while (!source.IsEmpty || flush); + } + + private void FlushPipeSynchronously() + { + var flush = _writer.FlushAsync(_cancellationToken); + var result = flush.IsCompletedSuccessfully + ? flush.Result + : flush.AsTask().GetAwaiter().GetResult(); + + ThrowIfCanceled(result, _cancellationToken); + _unflushedBytes = 0; + } + + private void FlushBuffer() + { + if (_index == 0) + { + return; + } + + Encode(_buffer.AsSpan(0, _index), flush: false); + _index = 0; + } + + private void EnsureBuffer(int sizeHint) + { + ThrowIfDisposed(); + ArgumentOutOfRangeException.ThrowIfNegative(sizeHint); + + if (sizeHint == 0) + { + sizeHint = 1; + } + + if (sizeHint <= _bufferCapacity - _index) + { + return; + } + + FlushBuffer(); + + if (sizeHint <= _bufferCapacity) + { + return; + } + + var newBuffer = ArrayPool.Shared.Rent(sizeHint); + ArrayPool.Shared.Return(_buffer); + _buffer = newBuffer; + _bufferCapacity = sizeHint; + } + + private void ThrowIfDisposed() + { + ObjectDisposedException.ThrowIf(_disposed, this); + } + + private bool NeedsFlush => _index != 0 || _unflushedBytes != 0 || _encoderNeedsFlush; + } +}