diff --git a/src/Servus.Akka/Transport/IListenerFactory.cs b/src/Servus.Akka/Transport/IListenerFactory.cs index 1d3da4ab2..1c94f8be6 100644 --- a/src/Servus.Akka/Transport/IListenerFactory.cs +++ b/src/Servus.Akka/Transport/IListenerFactory.cs @@ -5,5 +5,5 @@ namespace Servus.Akka.Transport; public interface IListenerFactory { - Source, Task> Bind(ListenerOptions options); + Source, Task> Bind(ListenerOptions options); } diff --git a/src/Servus.Akka/Transport/Quic/Listener/QuicListenerFactory.cs b/src/Servus.Akka/Transport/Quic/Listener/QuicListenerFactory.cs index 11807d675..a4a6aaa0f 100644 --- a/src/Servus.Akka/Transport/Quic/Listener/QuicListenerFactory.cs +++ b/src/Servus.Akka/Transport/Quic/Listener/QuicListenerFactory.cs @@ -5,7 +5,7 @@ namespace Servus.Akka.Transport.Quic.Listener; public sealed class QuicListenerFactory : IListenerFactory { - public Source, Task> Bind(ListenerOptions options) + public Source, Task> Bind(ListenerOptions options) { if (options is not QuicListenerOptions quicOptions) { diff --git a/src/Servus.Akka/Transport/Quic/Listener/QuicListenerStage.cs b/src/Servus.Akka/Transport/Quic/Listener/QuicListenerStage.cs index 170f5f42a..3510e16a6 100644 --- a/src/Servus.Akka/Transport/Quic/Listener/QuicListenerStage.cs +++ b/src/Servus.Akka/Transport/Quic/Listener/QuicListenerStage.cs @@ -18,7 +18,7 @@ internal sealed record QuicAcceptFailed(Exception Error); internal sealed record QuicListenerBound(QuicListener Listener); internal sealed class QuicListenerStage - : GraphStageWithMaterializedValue>, Task> + : GraphStageWithMaterializedValue>, Task> { private readonly QuicListenerOptions _options; @@ -33,24 +33,24 @@ public QuicListenerStage(QuicListenerOptions options) Shape = new SourceShape>(_out); } - public override ILogicAndMaterializedValue CreateLogicAndMaterializedValue( + public override ILogicAndMaterializedValue> CreateLogicAndMaterializedValue( Attributes inheritedAttributes) { - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - return new LogicAndMaterializedValue(new Logic(this, tcs), tcs.Task); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + return new LogicAndMaterializedValue>(new Logic(this, tcs), tcs.Task); } [ExcludeFromCodeCoverage] private sealed class Logic : GraphStageLogic { private readonly QuicListenerStage _stage; - private readonly TaskCompletionSource _boundSignal; + private readonly TaskCompletionSource _boundSignal; private readonly Queue> _pendingConnections = new(); private QuicListener? _listener; private IActorRef _self = null!; private CancellationTokenSource? _cts; - public Logic(QuicListenerStage stage, TaskCompletionSource boundSignal) : base(stage.Shape) + public Logic(QuicListenerStage stage, TaskCompletionSource boundSignal) : base(stage.Shape) { _stage = stage; _boundSignal = boundSignal; @@ -149,7 +149,7 @@ private void OnReceive((IActorRef sender, object message) args) { case QuicListenerBound bound: _listener = bound.Listener; - _boundSignal.TrySetResult(); + _boundSignal.TrySetResult(_listener.LocalEndPoint.Port); _ = AcceptLoopAsync(_listener, _self, _cts!.Token); break; case QuicConnectionAccepted accepted: diff --git a/src/Servus.Akka/Transport/Tcp/Listener/TcpListenerFactory.cs b/src/Servus.Akka/Transport/Tcp/Listener/TcpListenerFactory.cs index d64a8317b..ade234c64 100644 --- a/src/Servus.Akka/Transport/Tcp/Listener/TcpListenerFactory.cs +++ b/src/Servus.Akka/Transport/Tcp/Listener/TcpListenerFactory.cs @@ -5,7 +5,7 @@ namespace Servus.Akka.Transport.Tcp.Listener; public sealed class TcpListenerFactory : IListenerFactory { - public Source, Task> Bind(ListenerOptions options) + public Source, Task> Bind(ListenerOptions options) { if (options is not TcpListenerOptions tcpOptions) { diff --git a/src/Servus.Akka/Transport/Tcp/Listener/TcpListenerStage.cs b/src/Servus.Akka/Transport/Tcp/Listener/TcpListenerStage.cs index 7477c7578..91045a772 100644 --- a/src/Servus.Akka/Transport/Tcp/Listener/TcpListenerStage.cs +++ b/src/Servus.Akka/Transport/Tcp/Listener/TcpListenerStage.cs @@ -20,7 +20,7 @@ internal sealed record TcpConnectionReady(Flow>, Task> + : GraphStageWithMaterializedValue>, Task> { private readonly TcpListenerOptions _options; @@ -35,24 +35,24 @@ public TcpListenerStage(TcpListenerOptions options) Shape = new SourceShape>(_out); } - public override ILogicAndMaterializedValue CreateLogicAndMaterializedValue( + public override ILogicAndMaterializedValue> CreateLogicAndMaterializedValue( Attributes inheritedAttributes) { - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - return new LogicAndMaterializedValue(new Logic(this, tcs), tcs.Task); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + return new LogicAndMaterializedValue>(new Logic(this, tcs), tcs.Task); } [ExcludeFromCodeCoverage] private sealed class Logic : GraphStageLogic { private readonly TcpListenerStage _stage; - private readonly TaskCompletionSource _boundSignal; + private readonly TaskCompletionSource _boundSignal; private readonly Queue> _pendingConnections = new(); private TcpListener? _listener; private IActorRef _self = null!; private CancellationTokenSource? _cts; - public Logic(TcpListenerStage stage, TaskCompletionSource boundSignal) : base(stage.Shape) + public Logic(TcpListenerStage stage, TaskCompletionSource boundSignal) : base(stage.Shape) { _stage = stage; _boundSignal = boundSignal; @@ -81,7 +81,8 @@ public override void PreStart() } _listener.Start(_stage._options.Backlog); - _boundSignal.TrySetResult(); + var actualPort = ((IPEndPoint)_listener.LocalEndpoint).Port; + _boundSignal.TrySetResult(actualPort); _ = AcceptLoopAsync(_listener, _self, _cts.Token); } diff --git a/src/Servus.Akka/Transport/TransportFactory.cs b/src/Servus.Akka/Transport/TransportFactory.cs index 982ebee6e..f50358233 100644 --- a/src/Servus.Akka/Transport/TransportFactory.cs +++ b/src/Servus.Akka/Transport/TransportFactory.cs @@ -10,11 +10,11 @@ namespace Servus.Akka.Transport; public static class TransportFactory { - public static Source, Task> CreateTcpListener( + public static Source, Task> CreateTcpListener( TcpListenerOptions options) => new TcpListenerFactory().Bind(options); - public static Source, Task> CreateQuicListener( + public static Source, Task> CreateQuicListener( QuicListenerOptions options) => new QuicListenerFactory().Bind(options); diff --git a/src/TurboHTTP.API.Tests/verify/CoreAPISpec.ApproveCore.DotNet.verified.txt b/src/TurboHTTP.API.Tests/verify/CoreAPISpec.ApproveCore.DotNet.verified.txt index 0d7629ce8..bc51fae57 100644 --- a/src/TurboHTTP.API.Tests/verify/CoreAPISpec.ApproveCore.DotNet.verified.txt +++ b/src/TurboHTTP.API.Tests/verify/CoreAPISpec.ApproveCore.DotNet.verified.txt @@ -333,59 +333,7 @@ namespace TurboHTTP.Server.Context.Features } namespace TurboHTTP.Server.Context { - public interface ITurboFormCollection : System.Collections.Generic.IEnumerable>, System.Collections.IEnumerable - { - int Count { get; } - TurboHTTP.Server.Context.ITurboFormFileCollection Files { get; } - Microsoft.Extensions.Primitives.StringValues this[string key] { get; } - System.Collections.Generic.ICollection Keys { get; } - bool ContainsKey(string key); - } - public interface ITurboFormFile - { - string ContentType { get; } - string FileName { get; } - long Length { get; } - string Name { get; } - void CopyTo(System.IO.Stream target); - System.Threading.Tasks.Task CopyToAsync(System.IO.Stream target, System.Threading.CancellationToken cancellationToken = default); - System.IO.Stream OpenReadStream(); - } - public interface ITurboFormFileCollection : System.Collections.Generic.IEnumerable, System.Collections.IEnumerable - { - int Count { get; } - TurboHTTP.Server.Context.ITurboFormFile this[int index] { get; } - TurboHTTP.Server.Context.ITurboFormFile? this[string name] { get; } - TurboHTTP.Server.Context.ITurboFormFile? GetFile(string name); - System.Collections.Generic.IReadOnlyList GetFiles(string name); - } - public interface ITurboHeaderDictionary : System.Collections.Generic.IEnumerable>, System.Collections.IEnumerable - { - long? ContentLength { get; set; } - int Count { get; } - Microsoft.Extensions.Primitives.StringValues this[string key] { get; set; } - System.Collections.Generic.ICollection Keys { get; } - void Add(string key, Microsoft.Extensions.Primitives.StringValues value); - void Clear(); - bool ContainsKey(string key); - bool Remove(string key); - bool TryGetValue(string key, out Microsoft.Extensions.Primitives.StringValues value); - } - public interface ITurboQueryCollection : System.Collections.Generic.IEnumerable>, System.Collections.IEnumerable - { - int Count { get; } - Microsoft.Extensions.Primitives.StringValues this[string key] { get; } - System.Collections.Generic.ICollection Keys { get; } - bool ContainsKey(string key); - bool TryGetValue(string key, out Microsoft.Extensions.Primitives.StringValues value); - } - public interface ITurboRequestCookieCollection : System.Collections.Generic.IEnumerable>, System.Collections.IEnumerable - { - int Count { get; } - string? this[string key] { get; } - System.Collections.Generic.ICollection Keys { get; } - bool ContainsKey(string key); - } + public interface ITurboHeaderDictionary : Microsoft.AspNetCore.Http.IHeaderDictionary, System.Collections.Generic.ICollection>, System.Collections.Generic.IDictionary, System.Collections.Generic.IEnumerable>, System.Collections.IEnumerable { } } namespace TurboHTTP.Server { diff --git a/src/TurboHTTP.AcceptanceTests/H11/ErrorHandlingSpec.cs b/src/TurboHTTP.AcceptanceTests/H11/ErrorHandlingSpec.cs index 779dcd128..c5e9de570 100644 --- a/src/TurboHTTP.AcceptanceTests/H11/ErrorHandlingSpec.cs +++ b/src/TurboHTTP.AcceptanceTests/H11/ErrorHandlingSpec.cs @@ -196,7 +196,7 @@ public async Task ErrorHandling_should_return_4xx_status_code_400() Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } - [Fact(Timeout = 5000)] + [Fact(Timeout = 10000)] [Trait("RFC", "RFC9110-15.5")] public async Task ErrorHandling_should_return_4xx_status_code_401() { diff --git a/src/TurboHTTP.AcceptanceTests/H11/RedirectSpec.cs b/src/TurboHTTP.AcceptanceTests/H11/RedirectSpec.cs index 8a9ee0c1a..7d92d03d8 100644 --- a/src/TurboHTTP.AcceptanceTests/H11/RedirectSpec.cs +++ b/src/TurboHTTP.AcceptanceTests/H11/RedirectSpec.cs @@ -115,7 +115,7 @@ public async Task Redirect_should_follow_get_308_to_hello() Assert.Equal("Hello World", body); } - [Theory(Timeout = 5000)] + [Theory(Timeout = 10000)] [InlineData(1)] [InlineData(3)] [InlineData(5)] diff --git a/src/TurboHTTP.AcceptanceTests/TLS/ConnectionSpec.cs b/src/TurboHTTP.AcceptanceTests/TLS/ConnectionSpec.cs index b98504c98..daa85ae19 100644 --- a/src/TurboHTTP.AcceptanceTests/TLS/ConnectionSpec.cs +++ b/src/TurboHTTP.AcceptanceTests/TLS/ConnectionSpec.cs @@ -97,7 +97,7 @@ public async Task Connection_should_default_to_keep_alive_without_connection_hea Assert.Equal("default", body); } - [Fact(Timeout = 5000)] + [Fact(Timeout = 10000)] [Trait("RFC", "RFC9110-7.8")] public async Task Connection_101_switching_protocols_must_not_be_reusable_for_http() { diff --git a/src/TurboHTTP.AcceptanceTests/xunit.runner.json b/src/TurboHTTP.AcceptanceTests/xunit.runner.json index 73179ea81..bf2c57588 100644 --- a/src/TurboHTTP.AcceptanceTests/xunit.runner.json +++ b/src/TurboHTTP.AcceptanceTests/xunit.runner.json @@ -2,5 +2,5 @@ "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", "parallelizeTestCollections": true, "parallelizeAssembly": false, - "maxParallelThreads": 4 + "maxParallelThreads": 8 } diff --git a/src/TurboHTTP.IntegrationTests.Server/BodyFloodReproSpec.cs b/src/TurboHTTP.IntegrationTests.Server/BodyFloodReproSpec.cs new file mode 100644 index 000000000..bcbdb5431 --- /dev/null +++ b/src/TurboHTTP.IntegrationTests.Server/BodyFloodReproSpec.cs @@ -0,0 +1,107 @@ +using System.Net; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Servus.Akka.Transport; +using TurboHTTP.IntegrationTests.Server.Shared; +using TurboHTTP.Server; + +namespace TurboHTTP.IntegrationTests.Server; + +[Collection("ServerStress")] +public sealed class BodyFloodReproSpec : ServerSpecBase +{ + private static readonly byte[] Payload = new byte[1 * 1024 * 1024]; + + protected override void ConfigureServer(WebApplicationBuilder builder, ushort port) + { + builder.Host.UseTurboHttp(options => + { + options.Bind(new TcpListenerOptions { Host = "127.0.0.1", Port = port }); + }); + } + + protected override void ConfigureEndpoints(WebApplication app) + { + app.MapPost("/echo-size", async ctx => + { + long count = 0; + var buffer = new byte[64 * 1024]; + int read; + while ((read = await ctx.Request.Body.ReadAsync(buffer, CancellationToken)) > 0) + { + count += read; + } + + ctx.Response.ContentType = "text/plain"; + await ctx.Response.WriteAsync(count.ToString(), CancellationToken); + }); + } + + [Fact(Timeout = 10000)] + public async Task Post_1mb_body_should_return_correct_size() + { + var content = new ByteArrayContent(Payload); + var response = await Client.PostAsync( + new Uri($"http://127.0.0.1:{Port}/echo-size"), + content, + CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(CancellationToken); + Assert.Equal((1 * 1024 * 1024).ToString(), body); + } + + [Fact(Timeout = 120000)] + public async Task Concurrent_1mb_posts_should_all_succeed() + { + var concurrency = 50; + using var handler = new SocketsHttpHandler + { + MaxConnectionsPerServer = concurrency, + }; + using var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(60) }; + + var uri = new Uri($"http://127.0.0.1:{Port}/echo-size"); + var errors = new List(); + var succeeded = 0; + + var expectedSize = (1 * 1024 * 1024).ToString(); + var tasks = Enumerable.Range(0, concurrency).Select(async i => + { + try + { + var content = new ByteArrayContent(Payload); + var response = await client.PostAsync(uri, content, CancellationToken); + if (response.StatusCode != HttpStatusCode.OK) + { + lock (errors) errors.Add($"[{i}] status={response.StatusCode}"); + return; + } + + var body = await response.Content.ReadAsStringAsync(CancellationToken); + if (body == expectedSize) + { + Interlocked.Increment(ref succeeded); + } + else + { + lock (errors) errors.Add($"[{i}] body size mismatch: expected={expectedSize}, actual={body}"); + } + } + catch (Exception ex) + { + lock (errors) errors.Add($"[{i}] {ex.GetType().Name}: {ex.InnerException?.Message ?? ex.Message}"); + } + }).ToArray(); + + await Task.WhenAll(tasks); + + var msg = $"{succeeded}/{concurrency} succeeded"; + if (errors.Count > 0) + { + msg += $"\nErrors ({errors.Count}):\n" + string.Join("\n", errors.Take(10)); + } + + Assert.True(succeeded == concurrency, msg); + } +} diff --git a/src/TurboHTTP.IntegrationTests.Server/ConnectionCloseReproSpec.cs b/src/TurboHTTP.IntegrationTests.Server/ConnectionCloseReproSpec.cs new file mode 100644 index 000000000..221651449 --- /dev/null +++ b/src/TurboHTTP.IntegrationTests.Server/ConnectionCloseReproSpec.cs @@ -0,0 +1,107 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Servus.Akka.Transport; +using TurboHTTP.IntegrationTests.Server.Shared; +using TurboHTTP.Server; + +namespace TurboHTTP.IntegrationTests.Server; + +[Collection("Infrastructure")] +public sealed class ConnectionCloseReproSpec : ServerSpecBase +{ + protected override void ConfigureServer(WebApplicationBuilder builder, ushort port) + { + builder.Host.UseTurboHttp(options => + { + options.Bind(new TcpListenerOptions { Host = "127.0.0.1", Port = port }); + }); + } + + protected override void ConfigureEndpoints(WebApplication app) + { + app.MapGet("/ping", () => Results.Content("pong", "text/plain")); + } + + [Fact(Timeout = 15000)] + public async Task New_connection_after_graceful_close_should_succeed() + { + var uri = new Uri($"http://127.0.0.1:{Port}/ping"); + + using (var client1 = new HttpClient()) + { + var r1 = await client1.GetAsync(uri, CancellationToken); + Assert.Equal(HttpStatusCode.OK, r1.StatusCode); + } + + await Task.Delay(500, CancellationToken); + + using var client2 = new HttpClient(); + var r2 = await client2.GetAsync(uri, CancellationToken); + Assert.Equal(HttpStatusCode.OK, r2.StatusCode); + } + + [Fact(Timeout = 15000)] + public async Task New_connection_after_tcp_rst_should_succeed() + { + using (var socket = new TcpClient()) + { + await socket.ConnectAsync("127.0.0.1", Port, CancellationToken); + socket.LingerState = new LingerOption(true, 0); + } + + await Task.Delay(500, CancellationToken); + + var uri = new Uri($"http://127.0.0.1:{Port}/ping"); + using var client = new HttpClient(); + var r = await client.GetAsync(uri, CancellationToken); + Assert.Equal(HttpStatusCode.OK, r.StatusCode); + } + + [Fact(Timeout = 15000)] + public async Task New_connection_after_request_and_rst_should_succeed() + { + using (var socket = new TcpClient()) + { + await socket.ConnectAsync("127.0.0.1", Port, CancellationToken); + socket.ReceiveTimeout = 5000; + var stream = socket.GetStream(); + + var request = Encoding.ASCII.GetBytes("GET /ping HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n"); + await stream.WriteAsync(request, CancellationToken); + await stream.FlushAsync(CancellationToken); + + var buffer = new byte[4096]; + var totalRead = 0; + using var readCts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken); + readCts.CancelAfter(TimeSpan.FromSeconds(10)); + try + { + while (totalRead == 0) + { + var read = await stream.ReadAsync(buffer, readCts.Token); + totalRead += read; + if (read == 0) + { + break; + } + } + } + catch (OperationCanceledException) + { + } + Assert.True(totalRead > 0, "Should have received response"); + + socket.LingerState = new LingerOption(true, 0); + } + + await Task.Delay(500, CancellationToken); + + var uri = new Uri($"http://127.0.0.1:{Port}/ping"); + using var client = new HttpClient(); + var r = await client.GetAsync(uri, CancellationToken); + Assert.Equal(HttpStatusCode.OK, r.StatusCode); + } +} diff --git a/src/TurboHTTP.IntegrationTests.Server/DynamicPortSpec.cs b/src/TurboHTTP.IntegrationTests.Server/DynamicPortSpec.cs new file mode 100644 index 000000000..d4efe9b5d --- /dev/null +++ b/src/TurboHTTP.IntegrationTests.Server/DynamicPortSpec.cs @@ -0,0 +1,93 @@ +using System.Net; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Servus.Akka.Transport; +using TurboHTTP.Server; + +namespace TurboHTTP.IntegrationTests.Server; + +public sealed class DynamicPortSpec : IAsyncLifetime +{ + private WebApplication? _app; + private HttpClient? _client; + + private static CancellationToken CancellationToken => TestContext.Current.CancellationToken; + + public async ValueTask InitializeAsync() + { + var builder = WebApplication.CreateBuilder(); + builder.Logging.ClearProviders(); + + builder.Host.UseTurboHttp(options => + { + options.Listen(IPAddress.Loopback, 0, lo => + lo.Protocols = HttpProtocols.Http1); + }); + + _app = builder.Build(); + _app.MapGet("/ping", () => Results.Content("pong", "text/plain")); + await _app.StartAsync(); + _client = new HttpClient(); + } + + public async ValueTask DisposeAsync() + { + _client?.Dispose(); + if (_app is not null) + { + await _app.StopAsync(); + await _app.DisposeAsync(); + } + } + + [Fact(Timeout = 10000)] + public void Address_feature_should_report_non_zero_port() + { + var addresses = _app!.Services.GetRequiredService() + .Features.Get()! + .Addresses + .ToArray(); + + Assert.Single(addresses); + var uri = new Uri(addresses[0]); + Assert.NotEqual(0, uri.Port); + } + + [Fact(Timeout = 10000)] + public async Task Request_to_dynamic_port_should_succeed() + { + var addresses = _app!.Services.GetRequiredService() + .Features.Get()! + .Addresses + .ToArray(); + + var baseUri = new Uri(addresses[0]); + var response = await _client!.GetAsync(new Uri(baseUri, "/ping"), CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(CancellationToken); + Assert.Equal("pong", body); + } + + [Fact(Timeout = 30000)] + public async Task Multiple_requests_to_dynamic_port_should_all_succeed() + { + var addresses = _app!.Services.GetRequiredService() + .Features.Get()! + .Addresses + .ToArray(); + + var baseUri = new Uri(addresses[0]); + + var tasks = Enumerable.Range(0, 10) + .Select(_ => _client!.GetAsync(new Uri(baseUri, "/ping"), CancellationToken)); + + var responses = await Task.WhenAll(tasks); + + Assert.All(responses, r => Assert.Equal(HttpStatusCode.OK, r.StatusCode)); + } +} diff --git a/src/TurboHTTP.IntegrationTests.Server/Hosting/HttpsConnectionSpec.cs b/src/TurboHTTP.IntegrationTests.Server/Hosting/HttpsConnectionSpec.cs index 371770144..8ae6ca64e 100644 --- a/src/TurboHTTP.IntegrationTests.Server/Hosting/HttpsConnectionSpec.cs +++ b/src/TurboHTTP.IntegrationTests.Server/Hosting/HttpsConnectionSpec.cs @@ -7,6 +7,7 @@ namespace TurboHTTP.IntegrationTests.Server.Hosting; +[Collection("Infrastructure")] public sealed class HttpsConnectionSpec : ServerSpecBase { protected override void ConfigureServer(WebApplicationBuilder builder, ushort port) diff --git a/src/TurboHTTP.IntegrationTests.Server/Hosting/Tls/ClientCertificateModeAllowSpec.cs b/src/TurboHTTP.IntegrationTests.Server/Hosting/Tls/ClientCertificateModeAllowSpec.cs index bd804f21b..48bbaa07b 100644 --- a/src/TurboHTTP.IntegrationTests.Server/Hosting/Tls/ClientCertificateModeAllowSpec.cs +++ b/src/TurboHTTP.IntegrationTests.Server/Hosting/Tls/ClientCertificateModeAllowSpec.cs @@ -8,6 +8,7 @@ namespace TurboHTTP.IntegrationTests.Server.Hosting.Tls; +[Collection("Infrastructure")] public sealed class ClientCertificateModeAllowSpec : ServerSpecBase { private X509Certificate2? _serverCert; diff --git a/src/TurboHTTP.IntegrationTests.Server/Hosting/Tls/ClientCertificateModeRequireSpec.cs b/src/TurboHTTP.IntegrationTests.Server/Hosting/Tls/ClientCertificateModeRequireSpec.cs index 433c1893f..a0eaf5c4f 100644 --- a/src/TurboHTTP.IntegrationTests.Server/Hosting/Tls/ClientCertificateModeRequireSpec.cs +++ b/src/TurboHTTP.IntegrationTests.Server/Hosting/Tls/ClientCertificateModeRequireSpec.cs @@ -8,6 +8,7 @@ namespace TurboHTTP.IntegrationTests.Server.Hosting.Tls; +[Collection("Infrastructure")] public sealed class ClientCertificateModeRequireSpec : ServerSpecBase { private X509Certificate2? _serverCert; diff --git a/src/TurboHTTP.IntegrationTests.Server/Hosting/Tls/SniCertSelectionSpec.cs b/src/TurboHTTP.IntegrationTests.Server/Hosting/Tls/SniCertSelectionSpec.cs index be5aa39e4..d230a0b7c 100644 --- a/src/TurboHTTP.IntegrationTests.Server/Hosting/Tls/SniCertSelectionSpec.cs +++ b/src/TurboHTTP.IntegrationTests.Server/Hosting/Tls/SniCertSelectionSpec.cs @@ -7,6 +7,7 @@ namespace TurboHTTP.IntegrationTests.Server.Hosting.Tls; +[Collection("Infrastructure")] public sealed class SniCertSelectionSpec : ServerSpecBase { private X509Certificate2? _certA; diff --git a/src/TurboHTTP.IntegrationTests.Server/Hosting/Tls/TlsHandshakeFeatureSpec.cs b/src/TurboHTTP.IntegrationTests.Server/Hosting/Tls/TlsHandshakeFeatureSpec.cs index d8ed79dab..0ffc11df6 100644 --- a/src/TurboHTTP.IntegrationTests.Server/Hosting/Tls/TlsHandshakeFeatureSpec.cs +++ b/src/TurboHTTP.IntegrationTests.Server/Hosting/Tls/TlsHandshakeFeatureSpec.cs @@ -8,6 +8,7 @@ namespace TurboHTTP.IntegrationTests.Server.Hosting.Tls; +[Collection("Infrastructure")] public sealed class TlsHandshakeFeatureSpec : ServerSpecBase { protected override void ConfigureServer(WebApplicationBuilder builder, ushort port) diff --git a/src/TurboHTTP.IntegrationTests.Server/Infrastructure/ConnectionLimitSpec.cs b/src/TurboHTTP.IntegrationTests.Server/Infrastructure/ConnectionLimitSpec.cs index 83b79e4be..bc0c8e337 100644 --- a/src/TurboHTTP.IntegrationTests.Server/Infrastructure/ConnectionLimitSpec.cs +++ b/src/TurboHTTP.IntegrationTests.Server/Infrastructure/ConnectionLimitSpec.cs @@ -7,6 +7,7 @@ namespace TurboHTTP.IntegrationTests.Server.Infrastructure; +[Collection("Infrastructure")] public sealed class ConnectionLimitSpec : ServerSpecBase { private readonly TaskCompletionSource _slot1Gate = new(TaskCreationOptions.RunContinuationsAsynchronously); diff --git a/src/TurboHTTP.IntegrationTests.Server/Infrastructure/GracefulShutdownSpec.cs b/src/TurboHTTP.IntegrationTests.Server/Infrastructure/GracefulShutdownSpec.cs index 2c3c443b4..283dc2334 100644 --- a/src/TurboHTTP.IntegrationTests.Server/Infrastructure/GracefulShutdownSpec.cs +++ b/src/TurboHTTP.IntegrationTests.Server/Infrastructure/GracefulShutdownSpec.cs @@ -7,6 +7,7 @@ namespace TurboHTTP.IntegrationTests.Server.Infrastructure; +[Collection("Infrastructure")] public sealed class GracefulShutdownSpec : ServerSpecBase { private readonly TaskCompletionSource _handlerGate = new(TaskCreationOptions.RunContinuationsAsynchronously); @@ -37,7 +38,7 @@ public override async ValueTask DisposeAsync() await base.DisposeAsync(); } - [Fact(Timeout = 20000)] + [Fact(Timeout = 30000)] public async Task Shutdown_should_complete_inflight_request() { var handlerStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); diff --git a/src/TurboHTTP.IntegrationTests.Server/Infrastructure/TimeoutSpec.cs b/src/TurboHTTP.IntegrationTests.Server/Infrastructure/TimeoutSpec.cs index 20e7bc037..e719d09c1 100644 --- a/src/TurboHTTP.IntegrationTests.Server/Infrastructure/TimeoutSpec.cs +++ b/src/TurboHTTP.IntegrationTests.Server/Infrastructure/TimeoutSpec.cs @@ -9,6 +9,7 @@ namespace TurboHTTP.IntegrationTests.Server.Infrastructure; +[Collection("Infrastructure")] public sealed class TimeoutSpec : ServerSpecBase { protected override void ConfigureServer(WebApplicationBuilder builder, ushort port) diff --git a/src/TurboHTTP.IntegrationTests.Server/Lifecycle/ServerSmokeSpec.cs b/src/TurboHTTP.IntegrationTests.Server/Lifecycle/ServerSmokeSpec.cs index 9893217f5..eb5fdd183 100644 --- a/src/TurboHTTP.IntegrationTests.Server/Lifecycle/ServerSmokeSpec.cs +++ b/src/TurboHTTP.IntegrationTests.Server/Lifecycle/ServerSmokeSpec.cs @@ -1,44 +1,22 @@ using System.Net; using System.Text.Json; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Servus.Akka.Transport; using TurboHTTP.IntegrationTests.Server.Shared; -using TurboHTTP.Server; namespace TurboHTTP.IntegrationTests.Server.Lifecycle; -public sealed class ServerSmokeSpec : ServerSpecBase +public sealed class ServerSmokeSpec(TurboServerFixture server) : IDisposable { - protected override void ConfigureServer(WebApplicationBuilder builder, ushort port) - { - builder.Host.UseTurboHttp(options => - { - options.Bind(new TcpListenerOptions { Host = "127.0.0.1", Port = port }); - }); - } + private readonly HttpClient _client = server.CreateClient(); - protected override void ConfigureEndpoints(WebApplication app) - { - app.MapGet("/hello", () => Results.Ok("Hello from TurboHTTP Server")); - app.MapPost("/echo", async (HttpContext ctx) => - { - using var reader = new StreamReader(ctx.Request.Body); - var body = await reader.ReadToEndAsync(CancellationToken); - return Results.Ok(body); - }); - app.MapGet("/connection-info", (HttpContext ctx) => - { - var remoteIp = ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown"; - return Results.Ok(remoteIp); - }); - } + private static CancellationToken CancellationToken => TestContext.Current.CancellationToken; + + public void Dispose() => _client.Dispose(); [Fact(Timeout = 15000)] public async Task Server_should_respond_to_get_request() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/hello"), + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/hello"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -51,12 +29,12 @@ public async Task Server_should_respond_to_get_request() public async Task Server_should_echo_post_body() { var payload = "test payload"; - var request = new HttpRequestMessage(HttpMethod.Post, $"http://127.0.0.1:{Port}/echo") + var request = new HttpRequestMessage(HttpMethod.Post, $"http://127.0.0.1:{server.Port}/echo") { Content = new StringContent(payload) }; - var response = await Client.SendAsync(request, CancellationToken); + var response = await _client.SendAsync(request, CancellationToken); var body = await response.Content.ReadAsStringAsync(CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -67,8 +45,8 @@ public async Task Server_should_echo_post_body() [Fact(Timeout = 15000)] public async Task Server_should_return_404_for_unregistered_route() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/nonexistent"), + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/nonexistent"), CancellationToken); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); @@ -77,8 +55,8 @@ public async Task Server_should_return_404_for_unregistered_route() [Fact(Timeout = 15000)] public async Task Server_should_expose_remote_ip() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/connection-info"), + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/connection-info"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); diff --git a/src/TurboHTTP.IntegrationTests.Server/Middleware/MiddlewareSpec.cs b/src/TurboHTTP.IntegrationTests.Server/Middleware/MiddlewareSpec.cs index d33560ea2..1c87bc910 100644 --- a/src/TurboHTTP.IntegrationTests.Server/Middleware/MiddlewareSpec.cs +++ b/src/TurboHTTP.IntegrationTests.Server/Middleware/MiddlewareSpec.cs @@ -1,54 +1,21 @@ using System.Net; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Servus.Akka.Transport; using TurboHTTP.IntegrationTests.Server.Shared; -using TurboHTTP.Server; namespace TurboHTTP.IntegrationTests.Server.Middleware; -public sealed class MiddlewareSpec : ServerSpecBase +public sealed class MiddlewareSpec(TurboServerFixture server) : IDisposable { - protected override void ConfigureServer(WebApplicationBuilder builder, ushort port) - { - builder.Host.UseTurboHttp(options => - { - options.Bind(new TcpListenerOptions { Host = "127.0.0.1", Port = port }); - }); - } + private readonly HttpClient _client = server.CreateClient(); - protected override void ConfigureEndpoints(WebApplication app) - { - app.Use(async (ctx, next) => - { - ctx.Response.Headers.XPoweredBy = "TurboHTTP"; - await next(ctx); - }); + private static CancellationToken CancellationToken => TestContext.Current.CancellationToken; - app.MapWhen(ctx => ctx.Request.Path.StartsWithSegments("/api"), api => - { - api.Use(async (ctx, next) => - { - ctx.Response.Headers["X-Api-Version"] = "2.0"; - await next(ctx); - }); - api.UseRouting(); - api.UseEndpoints(endpoints => - { - endpoints.MapGet("/api/data", () => Results.Ok(new { value = 42 })); - }); - }); - - app.MapGet("/hello", () => Results.Ok("hello")); - app.MapGet("/api/data", () => Results.Ok(new { value = 42 })); - app.MapGet("/other", () => Results.Ok("other")); - } + public void Dispose() => _client.Dispose(); [Fact(Timeout = 15000)] public async Task Global_middleware_should_set_response_header() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/hello"), + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/hello"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -59,8 +26,8 @@ public async Task Global_middleware_should_set_response_header() [Fact(Timeout = 15000)] public async Task Mapped_middleware_should_apply_to_matching_path() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/api/data"), + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/api/data"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -71,8 +38,8 @@ public async Task Mapped_middleware_should_apply_to_matching_path() [Fact(Timeout = 15000)] public async Task Mapped_middleware_should_not_apply_to_other_paths() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/other"), + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/other"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -82,8 +49,8 @@ public async Task Mapped_middleware_should_not_apply_to_other_paths() [Fact(Timeout = 15000)] public async Task Global_middleware_should_apply_to_all_paths() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/other"), + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/other"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); diff --git a/src/TurboHTTP.IntegrationTests.Server/Routing/ConnectionInfoSpec.cs b/src/TurboHTTP.IntegrationTests.Server/Routing/ConnectionInfoSpec.cs index 7d2c61de3..66193b3b2 100644 --- a/src/TurboHTTP.IntegrationTests.Server/Routing/ConnectionInfoSpec.cs +++ b/src/TurboHTTP.IntegrationTests.Server/Routing/ConnectionInfoSpec.cs @@ -1,55 +1,36 @@ using System.Net; using System.Text.Json; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Servus.Akka.Transport; using TurboHTTP.IntegrationTests.Server.Shared; -using TurboHTTP.Server; namespace TurboHTTP.IntegrationTests.Server.Routing; -public sealed class ConnectionInfoSpec : ServerSpecBase +public sealed class ConnectionInfoSpec(TurboServerFixture server) : IDisposable { - protected override void ConfigureServer(WebApplicationBuilder builder, ushort port) - { - builder.Host.UseTurboHttp(options => - { - options.Bind(new TcpListenerOptions { Host = "127.0.0.1", Port = port }); - }); - } + private readonly HttpClient _client = server.CreateClient(); - protected override void ConfigureEndpoints(WebApplication app) - { - app.MapGet("/connection", (HttpContext ctx) => Results.Ok(new - { - remoteIp = ctx.Connection.RemoteIpAddress?.ToString(), - remotePort = ctx.Connection.RemotePort, - localIp = ctx.Connection.LocalIpAddress?.ToString(), - localPort = ctx.Connection.LocalPort - })); + private static CancellationToken CancellationToken => TestContext.Current.CancellationToken; - app.MapGet("/protocol", (HttpContext ctx) => Results.Ok(new { protocol = ctx.Request.Protocol })); - } + public void Dispose() => _client.Dispose(); [Fact(Timeout = 15000)] public async Task Connection_should_expose_local_ip_and_port() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/connection"), CancellationToken); + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/connection"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var json = JsonDocument.Parse( await response.Content.ReadAsStringAsync(CancellationToken)); Assert.Equal("127.0.0.1", json.RootElement.GetProperty("localIp").GetString()); - Assert.Equal(Port, json.RootElement.GetProperty("localPort").GetInt32()); + Assert.Equal(server.Port, json.RootElement.GetProperty("localPort").GetInt32()); } [Fact(Timeout = 15000)] public async Task Connection_should_expose_remote_ip() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/connection"), CancellationToken); + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/connection"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var json = JsonDocument.Parse( @@ -62,8 +43,8 @@ public async Task Connection_should_expose_remote_ip() [Fact(Timeout = 15000)] public async Task Request_should_expose_protocol_version() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/protocol"), CancellationToken); + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/protocol"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var json = JsonDocument.Parse( diff --git a/src/TurboHTTP.IntegrationTests.Server/Routing/ErrorHandlingSpec.cs b/src/TurboHTTP.IntegrationTests.Server/Routing/ErrorHandlingSpec.cs index 2bcd80916..9d8acc4bb 100644 --- a/src/TurboHTTP.IntegrationTests.Server/Routing/ErrorHandlingSpec.cs +++ b/src/TurboHTTP.IntegrationTests.Server/Routing/ErrorHandlingSpec.cs @@ -1,49 +1,21 @@ using System.Net; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Servus.Akka.Transport; using TurboHTTP.IntegrationTests.Server.Shared; -using TurboHTTP.Server; namespace TurboHTTP.IntegrationTests.Server.Routing; -public sealed class ErrorHandlingSpec : ServerSpecBase +public sealed class ErrorHandlingSpec(TurboServerFixture server) : IDisposable { - protected override void ConfigureServer(WebApplicationBuilder builder, ushort port) - { - builder.Host.UseTurboHttp(options => - { - options.Bind(new TcpListenerOptions { Host = "127.0.0.1", Port = port }); - }); - } + private readonly HttpClient _client = server.CreateClient(); - protected override void ConfigureEndpoints(WebApplication app) - { - app.MapGet("/throw-sync", () => - { - throw new InvalidOperationException("sync boom"); -#pragma warning disable CS0162 - return Results.Ok(); -#pragma warning restore CS0162 - }); + private static CancellationToken CancellationToken => TestContext.Current.CancellationToken; - app.MapGet("/throw-async", async () => - { - await Task.Yield(); - throw new InvalidOperationException("async boom"); -#pragma warning disable CS0162 - return Results.Ok(); -#pragma warning restore CS0162 - }); - - app.MapGet("/ok", () => Results.Ok("fine")); - } + public void Dispose() => _client.Dispose(); [Fact(Timeout = 15000)] public async Task Sync_handler_exception_should_return_500() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/throw-sync"), + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/throw-sync"), CancellationToken); Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); @@ -52,8 +24,8 @@ public async Task Sync_handler_exception_should_return_500() [Fact(Timeout = 15000)] public async Task Async_handler_exception_should_return_500() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/throw-async"), + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/throw-async"), CancellationToken); Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); @@ -62,12 +34,12 @@ public async Task Async_handler_exception_should_return_500() [Fact(Timeout = 15000)] public async Task Server_should_recover_after_handler_exception() { - await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/throw-sync"), + await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/throw-sync"), CancellationToken); - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/ok"), + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/ok"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); diff --git a/src/TurboHTTP.IntegrationTests.Server/Routing/ParameterBindingSpec.cs b/src/TurboHTTP.IntegrationTests.Server/Routing/ParameterBindingSpec.cs index e1989b8d2..c91b636ed 100644 --- a/src/TurboHTTP.IntegrationTests.Server/Routing/ParameterBindingSpec.cs +++ b/src/TurboHTTP.IntegrationTests.Server/Routing/ParameterBindingSpec.cs @@ -1,51 +1,22 @@ using System.Net; using System.Text.Json; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Servus.Akka.Transport; using TurboHTTP.IntegrationTests.Server.Shared; -using TurboHTTP.Server; namespace TurboHTTP.IntegrationTests.Server.Routing; -public sealed class ParameterBindingSpec : ServerSpecBase +public sealed class ParameterBindingSpec(TurboServerFixture server) : IDisposable { - protected override void ConfigureServer(WebApplicationBuilder builder, ushort port) - { - builder.Host.UseTurboHttp(options => - { - options.Bind(new TcpListenerOptions { Host = "127.0.0.1", Port = port }); - }); - } - - protected override void ConfigureEndpoints(WebApplication app) - { - app.MapGet("/users/{id:int}", (int id) => - Results.Ok(new { id })); + private readonly HttpClient _client = server.CreateClient(); - app.MapGet("/search", (string q) => - Results.Ok(new { query = q })); + private static CancellationToken CancellationToken => TestContext.Current.CancellationToken; - app.MapGet("/paged", (string q, int page) => - Results.Ok(new { query = q, page })); - - app.MapGet("/with-header", - ([FromHeader(Name = "X-Tenant")] string tenant) => - Results.Ok(new { tenant })); - - app.MapGet("/optional", (string? name) => - Results.Ok(new { name = name ?? "default" })); - - app.MapGet("/items/{category}/{id}", (string category, int id) => - Results.Ok(new { category, id })); - } + public void Dispose() => _client.Dispose(); [Fact(Timeout = 15000)] public async Task Route_param_should_bind_int_from_path() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/users/42"), CancellationToken); + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/users/42"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var json = JsonDocument.Parse( @@ -56,8 +27,8 @@ public async Task Route_param_should_bind_int_from_path() [Fact(Timeout = 15000)] public async Task Query_string_should_bind_string_param() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/search?q=turbohttp"), CancellationToken); + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/search?q=turbohttp"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var json = JsonDocument.Parse( @@ -68,8 +39,8 @@ public async Task Query_string_should_bind_string_param() [Fact(Timeout = 15000)] public async Task Multiple_query_params_should_bind() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/paged?q=test&page=3"), CancellationToken); + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/paged?q=test&page=3"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var json = JsonDocument.Parse( @@ -82,10 +53,10 @@ public async Task Multiple_query_params_should_bind() public async Task Header_should_bind_from_request_header() { var request = new HttpRequestMessage(HttpMethod.Get, - new Uri($"http://127.0.0.1:{Port}/with-header")); + new Uri($"http://127.0.0.1:{server.Port}/with-header")); request.Headers.Add("X-Tenant", "acme-corp"); - var response = await Client.SendAsync(request, CancellationToken); + var response = await _client.SendAsync(request, CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var json = JsonDocument.Parse( @@ -96,8 +67,8 @@ public async Task Header_should_bind_from_request_header() [Fact(Timeout = 15000)] public async Task Optional_param_should_use_default_when_missing() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/optional"), CancellationToken); + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/optional"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var json = JsonDocument.Parse( @@ -108,8 +79,8 @@ public async Task Optional_param_should_use_default_when_missing() [Fact(Timeout = 15000)] public async Task Optional_param_should_use_provided_value() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/optional?name=jan"), CancellationToken); + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/optional?name=jan"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var json = JsonDocument.Parse( @@ -120,8 +91,8 @@ public async Task Optional_param_should_use_provided_value() [Fact(Timeout = 15000)] public async Task Multiple_route_params_should_bind() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/items/electronics/99"), CancellationToken); + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/items/electronics/99"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var json = JsonDocument.Parse( diff --git a/src/TurboHTTP.IntegrationTests.Server/Routing/RequestBodySpec.cs b/src/TurboHTTP.IntegrationTests.Server/Routing/RequestBodySpec.cs index e6a42c1e0..15c9ac14d 100644 --- a/src/TurboHTTP.IntegrationTests.Server/Routing/RequestBodySpec.cs +++ b/src/TurboHTTP.IntegrationTests.Server/Routing/RequestBodySpec.cs @@ -1,55 +1,23 @@ using System.Net; using System.Text; using System.Text.Json; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Servus.Akka.Transport; using TurboHTTP.IntegrationTests.Server.Shared; -using TurboHTTP.Server; namespace TurboHTTP.IntegrationTests.Server.Routing; -public sealed class RequestBodySpec : ServerSpecBase +public sealed class RequestBodySpec(TurboServerFixture server) : IDisposable { - protected override void ConfigureServer(WebApplicationBuilder builder, ushort port) - { - builder.Host.UseTurboHttp(options => - { - options.Bind(new TcpListenerOptions { Host = "127.0.0.1", Port = port }); - }); - } + private readonly HttpClient _client = server.CreateClient(); - protected override void ConfigureEndpoints(WebApplication app) - { - app.MapPost("/echo-body", async (HttpContext ctx) => - { - using var reader = new StreamReader(ctx.Request.Body); - var body = await reader.ReadToEndAsync(); - return Results.Ok(new { body }); - }); + private static CancellationToken CancellationToken => TestContext.Current.CancellationToken; - app.MapPost("/echo-json", async (HttpContext ctx) => - { - using var reader = new StreamReader(ctx.Request.Body); - var raw = await reader.ReadToEndAsync(); - var parsed = JsonDocument.Parse(raw); - return Results.Ok(parsed.RootElement); - }); - - app.MapPost("/form", async (HttpContext ctx) => - { - var form = await ctx.Request.ReadFormAsync(); - var name = form["name"].ToString(); - var age = form["age"].ToString(); - return Results.Ok(new { name, age }); - }); - } + public void Dispose() => _client.Dispose(); [Fact(Timeout = 15000)] public async Task Post_should_receive_text_body() { - var response = await Client.PostAsync( - new Uri($"http://127.0.0.1:{Port}/echo-body"), + var response = await _client.PostAsync( + new Uri($"http://127.0.0.1:{server.Port}/echo-body"), new StringContent("hello server", Encoding.UTF8, "text/plain"), CancellationToken); @@ -67,8 +35,8 @@ public async Task Post_should_receive_json_body() Encoding.UTF8, "application/json"); - var response = await Client.PostAsync( - new Uri($"http://127.0.0.1:{Port}/echo-json"), + var response = await _client.PostAsync( + new Uri($"http://127.0.0.1:{server.Port}/echo-json"), jsonContent, CancellationToken); @@ -89,8 +57,8 @@ public async Task Post_should_receive_form_encoded_body() }; var content = new FormUrlEncodedContent(formData); - var response = await Client.PostAsync( - new Uri($"http://127.0.0.1:{Port}/form"), + var response = await _client.PostAsync( + new Uri($"http://127.0.0.1:{server.Port}/form"), content, CancellationToken); diff --git a/src/TurboHTTP.IntegrationTests.Server/Routing/ResponseHeadersSpec.cs b/src/TurboHTTP.IntegrationTests.Server/Routing/ResponseHeadersSpec.cs index 90ad094e5..9724e8072 100644 --- a/src/TurboHTTP.IntegrationTests.Server/Routing/ResponseHeadersSpec.cs +++ b/src/TurboHTTP.IntegrationTests.Server/Routing/ResponseHeadersSpec.cs @@ -1,50 +1,21 @@ using System.Net; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Servus.Akka.Transport; using TurboHTTP.IntegrationTests.Server.Shared; -using TurboHTTP.Server; namespace TurboHTTP.IntegrationTests.Server.Routing; -public sealed class ResponseHeadersSpec : ServerSpecBase +public sealed class ResponseHeadersSpec(TurboServerFixture server) : IDisposable { - protected override void ConfigureServer(WebApplicationBuilder builder, ushort port) - { - builder.Host.UseTurboHttp(options => - { - options.Bind(new TcpListenerOptions { Host = "127.0.0.1", Port = port }); - }); - } + private readonly HttpClient _client = server.CreateClient(); - protected override void ConfigureEndpoints(WebApplication app) - { - app.MapGet("/custom-header", (HttpContext ctx) => - { - ctx.Response.Headers["X-Request-Id"] = "abc-123"; - return Results.Ok("ok"); - }); + private static CancellationToken CancellationToken => TestContext.Current.CancellationToken; - app.MapGet("/multi-header", (HttpContext ctx) => - { - ctx.Response.Headers.Append("X-Tag", "alpha"); - ctx.Response.Headers.Append("X-Tag", "beta"); - return Results.Ok("ok"); - }); - - app.MapGet("/cache-headers", (HttpContext ctx) => - { - ctx.Response.Headers.CacheControl = "no-cache, no-store"; - ctx.Response.Headers.ETag = "\"v1\""; - return Results.Ok("cached"); - }); - } + public void Dispose() => _client.Dispose(); [Fact(Timeout = 15000)] public async Task Custom_response_header_should_arrive_at_client() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/custom-header"), + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/custom-header"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -55,8 +26,8 @@ public async Task Custom_response_header_should_arrive_at_client() [Fact(Timeout = 15000)] public async Task Multiple_values_for_same_header_should_arrive() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/multi-header"), + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/multi-header"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -69,8 +40,8 @@ public async Task Multiple_values_for_same_header_should_arrive() [Fact(Timeout = 15000)] public async Task Standard_cache_headers_should_arrive() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/cache-headers"), + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/cache-headers"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); diff --git a/src/TurboHTTP.IntegrationTests.Server/Routing/RoutingEdgeCasesSpec.cs b/src/TurboHTTP.IntegrationTests.Server/Routing/RoutingEdgeCasesSpec.cs index c22a28175..7beac651a 100644 --- a/src/TurboHTTP.IntegrationTests.Server/Routing/RoutingEdgeCasesSpec.cs +++ b/src/TurboHTTP.IntegrationTests.Server/Routing/RoutingEdgeCasesSpec.cs @@ -2,58 +2,23 @@ using System.Net.Http.Headers; using System.Text; using System.Text.Json; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Servus.Akka.Transport; using TurboHTTP.IntegrationTests.Server.Shared; -using TurboHTTP.Server; namespace TurboHTTP.IntegrationTests.Server.Routing; -public sealed class RoutingEdgeCasesSpec : ServerSpecBase +public sealed class RoutingEdgeCasesSpec(TurboServerFixture server) : IDisposable { - protected override void ConfigureServer(WebApplicationBuilder builder, ushort port) - { - builder.Host.UseTurboHttp(options => - { - options.Bind(new TcpListenerOptions { Host = "127.0.0.1", Port = port }); - }); - } + private readonly HttpClient _client = server.CreateClient(); - protected override void ConfigureEndpoints(WebApplication app) - { - app.MapGet("/multi", () => - Results.Ok(new { method = "GET" })); - app.MapPost("/multi", () => - Results.Ok(new { method = "POST" })); - app.MapPut("/multi", () => - Results.Ok(new { method = "PUT" })); - - app.MapPost("/upload", async (HttpContext ctx) => - { - var form = await ctx.Request.ReadFormAsync(); - var file = form.Files.GetFile("document"); - if (file is null) - { - return Results.BadRequest("No file"); - } - - using var reader = new StreamReader(file.OpenReadStream()); - var content = await reader.ReadToEndAsync(); - return Results.Ok(new - { - fileName = file.FileName, - size = file.Length, - content - }); - }); - } + private static CancellationToken CancellationToken => TestContext.Current.CancellationToken; + + public void Dispose() => _client.Dispose(); [Fact(Timeout = 15000)] public async Task Multi_method_route_should_handle_GET() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/multi"), CancellationToken); + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/multi"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var json = JsonDocument.Parse( @@ -65,12 +30,12 @@ public async Task Multi_method_route_should_handle_GET() public async Task Multi_method_route_should_handle_POST() { var request = new HttpRequestMessage(HttpMethod.Post, - new Uri($"http://127.0.0.1:{Port}/multi")) + new Uri($"http://127.0.0.1:{server.Port}/multi")) { Content = new StringContent("") }; - var response = await Client.SendAsync(request, CancellationToken); + var response = await _client.SendAsync(request, CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var json = JsonDocument.Parse( @@ -82,12 +47,12 @@ public async Task Multi_method_route_should_handle_POST() public async Task Multi_method_route_should_handle_PUT() { var request = new HttpRequestMessage(HttpMethod.Put, - new Uri($"http://127.0.0.1:{Port}/multi")) + new Uri($"http://127.0.0.1:{server.Port}/multi")) { Content = new StringContent("") }; - var response = await Client.SendAsync(request, CancellationToken); + var response = await _client.SendAsync(request, CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var json = JsonDocument.Parse( @@ -99,9 +64,9 @@ public async Task Multi_method_route_should_handle_PUT() public async Task Multi_method_route_should_return_404_for_unregistered_method() { var request = new HttpRequestMessage(HttpMethod.Delete, - new Uri($"http://127.0.0.1:{Port}/multi")); + new Uri($"http://127.0.0.1:{server.Port}/multi")); - var response = await Client.SendAsync(request, CancellationToken); + var response = await _client.SendAsync(request, CancellationToken); Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); } @@ -118,12 +83,12 @@ public async Task Upload_should_receive_multipart_file() multipart.Add(fileStream, "document", "test.txt"); var request = new HttpRequestMessage(HttpMethod.Post, - new Uri($"http://127.0.0.1:{Port}/upload")) + new Uri($"http://127.0.0.1:{server.Port}/upload")) { Content = multipart }; - var response = await Client.SendAsync(request, CancellationToken); + var response = await _client.SendAsync(request, CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync(CancellationToken)); diff --git a/src/TurboHTTP.IntegrationTests.Server/Shared/ServerStressCollection.cs b/src/TurboHTTP.IntegrationTests.Server/Shared/ServerStressCollection.cs new file mode 100644 index 000000000..39773a5da --- /dev/null +++ b/src/TurboHTTP.IntegrationTests.Server/Shared/ServerStressCollection.cs @@ -0,0 +1,11 @@ +using TurboHTTP.IntegrationTests.Server.Shared; + +[assembly: AssemblyFixture(typeof(TurboServerFixture))] + +namespace TurboHTTP.IntegrationTests.Server.Shared; + +[CollectionDefinition("ServerStress", DisableParallelization = true)] +public sealed class ServerStressCollection; + +[CollectionDefinition("Infrastructure", DisableParallelization = true)] +public sealed class InfrastructureCollection; diff --git a/src/TurboHTTP.IntegrationTests.Server/Shared/TurboServerFixture.cs b/src/TurboHTTP.IntegrationTests.Server/Shared/TurboServerFixture.cs new file mode 100644 index 000000000..4e4ba8225 --- /dev/null +++ b/src/TurboHTTP.IntegrationTests.Server/Shared/TurboServerFixture.cs @@ -0,0 +1,262 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Servus.Akka.Transport; +using TurboHTTP.Server; + +namespace TurboHTTP.IntegrationTests.Server.Shared; + +public sealed class TurboServerFixture : IAsyncLifetime +{ + private WebApplication? _app; + + public ushort Port { get; private set; } + + public HttpClient CreateClient() => new(new SocketsHttpHandler + { + PooledConnectionLifetime = TimeSpan.Zero + }); + + public async ValueTask InitializeAsync() + { + Port = GetFreePort(); + var builder = WebApplication.CreateBuilder(); + builder.Logging.ClearProviders(); + builder.Host.UseTurboHttp(options => + { + options.Bind(new TcpListenerOptions { Host = "127.0.0.1", Port = Port }); + }); + + _app = builder.Build(); + RegisterEndpoints(_app); + await _app.StartAsync(); + } + + public async ValueTask DisposeAsync() + { + if (_app is not null) + { + await _app.StopAsync(); + await _app.DisposeAsync(); + } + } + + private static void RegisterEndpoints(WebApplication app) + { + app.Use(async (ctx, next) => + { + ctx.Response.Headers.XPoweredBy = "TurboHTTP"; + await next(ctx); + }); + + app.MapWhen(ctx => ctx.Request.Path.StartsWithSegments("/api"), api => + { + api.Use(async (ctx, next) => + { + ctx.Response.Headers["X-Api-Version"] = "2.0"; + await next(ctx); + }); + api.UseRouting(); + api.UseEndpoints(endpoints => + { + endpoints.MapGet("/api/data", () => Results.Ok(new { value = 42 })); + }); + }); + + // Basic + app.MapGet("/ping", () => Results.Content("pong", "text/plain")); + app.MapGet("/hello", () => Results.Ok("Hello from TurboHTTP Server")); + app.MapGet("/other", () => Results.Ok("other")); + app.MapGet("/ok", () => Results.Ok("fine")); + app.MapGet("/echo", () => Results.Ok("ok")); + app.MapGet("/text", () => Results.Ok("hello world")); + app.MapGet("/api/data", () => Results.Ok(new { value = 42 })); + + // Echo / body + app.MapPost("/echo", async (HttpContext ctx) => + { + using var reader = new StreamReader(ctx.Request.Body); + var body = await reader.ReadToEndAsync(); + return Results.Ok(body); + }); + app.MapPost("/echo-body", async (HttpContext ctx) => + { + using var reader = new StreamReader(ctx.Request.Body); + var body = await reader.ReadToEndAsync(); + return Results.Ok(new { body }); + }); + app.MapPost("/echo-json", async (HttpContext ctx) => + { + using var reader = new StreamReader(ctx.Request.Body); + var raw = await reader.ReadToEndAsync(); + var parsed = JsonDocument.Parse(raw); + return Results.Ok(parsed.RootElement); + }); + app.MapPost("/form", async (HttpContext ctx) => + { + var form = await ctx.Request.ReadFormAsync(); + var name = form["name"].ToString(); + var age = form["age"].ToString(); + return Results.Ok(new { name, age }); + }); + + // Connection info + app.MapGet("/connection-info", (HttpContext ctx) => + { + var remoteIp = ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + return Results.Ok(remoteIp); + }); + app.MapGet("/connection", (HttpContext ctx) => Results.Ok(new + { + remoteIp = ctx.Connection.RemoteIpAddress?.ToString(), + remotePort = ctx.Connection.RemotePort, + localIp = ctx.Connection.LocalIpAddress?.ToString(), + localPort = ctx.Connection.LocalPort + })); + app.MapGet("/protocol", (HttpContext ctx) => Results.Ok(new { protocol = ctx.Request.Protocol })); + + // Error handling + app.MapGet("/throw-sync", () => + { + throw new InvalidOperationException("sync boom"); +#pragma warning disable CS0162 + return Results.Ok(); +#pragma warning restore CS0162 + }); + app.MapGet("/throw-async", async () => + { + await Task.Yield(); + throw new InvalidOperationException("async boom"); +#pragma warning disable CS0162 + return Results.Ok(); +#pragma warning restore CS0162 + }); + + // Parameter binding + app.MapGet("/users/{id:int}", (int id) => Results.Ok(new { id })); + app.MapGet("/search", (string q) => Results.Ok(new { query = q })); + app.MapGet("/paged", (string q, int page) => Results.Ok(new { query = q, page })); + app.MapGet("/with-header", ([FromHeader(Name = "X-Tenant")] string tenant) => Results.Ok(new { tenant })); + app.MapGet("/optional", (string? name) => Results.Ok(new { name = name ?? "default" })); + app.MapGet("/items/{category}/{id}", (string category, int id) => Results.Ok(new { category, id })); + + // Response headers + app.MapGet("/custom-header", (HttpContext ctx) => + { + ctx.Response.Headers["X-Request-Id"] = "abc-123"; + return Results.Ok("ok"); + }); + app.MapGet("/multi-header", (HttpContext ctx) => + { + ctx.Response.Headers.Append("X-Tag", "alpha"); + ctx.Response.Headers.Append("X-Tag", "beta"); + return Results.Ok("ok"); + }); + app.MapGet("/cache-headers", (HttpContext ctx) => + { + ctx.Response.Headers.CacheControl = "no-cache, no-store"; + ctx.Response.Headers.ETag = "\"v1\""; + return Results.Ok("cached"); + }); + + // Multi-method routing + app.MapGet("/multi", () => Results.Ok(new { method = "GET" })); + app.MapPost("/multi", () => Results.Ok(new { method = "POST" })); + app.MapPut("/multi", () => Results.Ok(new { method = "PUT" })); + app.MapPost("/upload", async (HttpContext ctx) => + { + var form = await ctx.Request.ReadFormAsync(); + var file = form.Files.GetFile("document"); + if (file is null) + { + return Results.BadRequest("No file"); + } + using var reader = new StreamReader(file.OpenReadStream()); + var content = await reader.ReadToEndAsync(); + return Results.Ok(new { fileName = file.FileName, size = file.Length, content }); + }); + + // Streaming + app.MapGet("/stream-bytes", () => + { + var chunks = new[] { new byte[] { 1, 2, 3 }, new byte[] { 4, 5, 6 }, new byte[] { 7, 8, 9 } }; + return Results.Stream(async stream => + { + foreach (var chunk in chunks) + { + await stream.WriteAsync(chunk); + } + }, "application/octet-stream"); + }); + app.MapGet("/stream-text", () => + { + var lines = new[] { "line1\n", "line2\n", "line3\n" }; + return Results.Stream(async stream => + { + foreach (var line in lines) + { + await stream.WriteAsync(Encoding.UTF8.GetBytes(line)); + } + }, "text/plain"); + }); + app.MapGet("/stream-large", () => + { + return Results.Stream(async stream => + { + var chunk = new byte[1024]; + Array.Fill(chunk, (byte)0xAB); + for (var i = 0; i < 100; i++) + { + await stream.WriteAsync(chunk); + } + }, "application/octet-stream"); + }); + app.MapGet("/stream-no-cl", () => + { + var chunks = new[] { "chunk1", "chunk2", "chunk3" }; + return Results.Stream(async stream => + { + foreach (var chunk in chunks) + { + await stream.WriteAsync(Encoding.UTF8.GetBytes(chunk)); + } + }, "text/plain"); + }); + app.MapGet("/with-cl", (HttpContext ctx) => + { + var body = Encoding.UTF8.GetBytes("exact-length-body"); + ctx.Response.StatusCode = 200; + ctx.Response.ContentType = "text/plain"; + ctx.Response.ContentLength = body.Length; + return ctx.Response.Body.WriteAsync(body).AsTask(); + }); + app.MapGet("/no-content", () => Results.NoContent()); + app.MapGet("/not-modified", () => Results.StatusCode(304)); + + // SSE + app.MapGet("/events", async (HttpContext ctx) => + { + ctx.Response.ContentType = "text/event-stream"; + var events = new[] { "event1", "event2" }; + foreach (var evt in events) + { + var data = Encoding.UTF8.GetBytes($"data: {evt}\n\n"); + await ctx.Response.Body.WriteAsync(data); + } + }); + } + + private static ushort GetFreePort() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return (ushort)port; + } +} diff --git a/src/TurboHTTP.IntegrationTests.Server/SharedPipelineSpec.cs b/src/TurboHTTP.IntegrationTests.Server/SharedPipelineSpec.cs new file mode 100644 index 000000000..767d73a9d --- /dev/null +++ b/src/TurboHTTP.IntegrationTests.Server/SharedPipelineSpec.cs @@ -0,0 +1,86 @@ +using System.Net; +using TurboHTTP.IntegrationTests.Server.Shared; + +namespace TurboHTTP.IntegrationTests.Server; + +public sealed class SharedPipelineBasicSpec(TurboServerFixture server) : IDisposable +{ + private readonly HttpClient _client = server.CreateClient(); + + private static CancellationToken CancellationToken => TestContext.Current.CancellationToken; + + public void Dispose() => _client.Dispose(); + + [Fact(Timeout = 10000)] + public async Task Single_request_should_succeed() + { + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/ping"), + CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact(Timeout = 15000)] + public async Task Sequential_requests_should_succeed() + { + var uri = new Uri($"http://127.0.0.1:{server.Port}/ping"); + + var r1 = await _client.GetAsync(uri, CancellationToken); + Assert.Equal(HttpStatusCode.OK, r1.StatusCode); + + var r2 = await _client.GetAsync(uri, CancellationToken); + Assert.Equal(HttpStatusCode.OK, r2.StatusCode); + } +} + +public sealed class SharedPipelineConcurrencySpec(TurboServerFixture server) : IDisposable +{ + private readonly HttpClient _client = server.CreateClient(); + + private static CancellationToken CancellationToken => TestContext.Current.CancellationToken; + + public void Dispose() => _client.Dispose(); + + [Fact(Timeout = 30000)] + public async Task Should_handle_50_concurrent_get_requests() + { + using var handler = new SocketsHttpHandler { MaxConnectionsPerServer = 50 }; + using var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(20) }; + var uri = new Uri($"http://127.0.0.1:{server.Port}/ping"); + + var tasks = Enumerable.Range(0, 50) + .Select(_ => client.GetAsync(uri, CancellationToken)); + + var responses = await Task.WhenAll(tasks); + Assert.All(responses, r => Assert.Equal(HttpStatusCode.OK, r.StatusCode)); + } +} + +public sealed class SharedPipelineResilienceSpec(TurboServerFixture server) : IDisposable +{ + private readonly HttpClient _client = server.CreateClient(); + + private static CancellationToken CancellationToken => TestContext.Current.CancellationToken; + + public void Dispose() => _client.Dispose(); + + [Fact(Timeout = 30000)] + public async Task Connection_after_tcp_abort_should_still_work() + { + var uri = new Uri($"http://127.0.0.1:{server.Port}/ping"); + + using (var socket = new System.Net.Sockets.TcpClient()) + { + await socket.ConnectAsync("127.0.0.1", server.Port, TestContext.Current.CancellationToken); + socket.LingerState = new System.Net.Sockets.LingerOption(true, 0); + } + + await Task.Delay(2000, CancellationToken); + + using var freshClient = new HttpClient(new SocketsHttpHandler()); + freshClient.Timeout = TimeSpan.FromSeconds(10); + var response = await freshClient.GetAsync(uri, CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } +} \ No newline at end of file diff --git a/src/TurboHTTP.IntegrationTests.Server/SseServerSpec.cs b/src/TurboHTTP.IntegrationTests.Server/SseServerSpec.cs index f89118a10..aa54ae39f 100644 --- a/src/TurboHTTP.IntegrationTests.Server/SseServerSpec.cs +++ b/src/TurboHTTP.IntegrationTests.Server/SseServerSpec.cs @@ -1,44 +1,21 @@ using System.Net; -using System.Text; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Servus.Akka.Transport; using TurboHTTP.IntegrationTests.Server.Shared; -using TurboHTTP.Server; namespace TurboHTTP.IntegrationTests.Server; -public sealed class SseServerSpec : ServerSpecBase +public sealed class SseServerSpec(TurboServerFixture server) : IDisposable { - protected override void ConfigureServer(WebApplicationBuilder builder, ushort port) - { - builder.Host.UseTurboHttp(options => - { - options.Bind(new TcpListenerOptions { Host = "127.0.0.1", Port = port }); - }); - } + private readonly HttpClient _client = server.CreateClient(); - protected override void ConfigureEndpoints(WebApplication app) - { - app.MapGet("/echo", () => Results.Ok("ok")); - app.MapGet("/text", () => Results.Ok("hello world")); - app.MapGet("/events", async (HttpContext ctx) => - { - ctx.Response.ContentType = "text/event-stream"; - var events = new[] { "event1", "event2" }; - foreach (var evt in events) - { - var data = Encoding.UTF8.GetBytes($"data: {evt}\n\n"); - await ctx.Response.Body.WriteAsync(data); - } - }); - } + private static CancellationToken CancellationToken => TestContext.Current.CancellationToken; + + public void Dispose() => _client.Dispose(); [Fact(Timeout = 15000)] public async Task Server_should_respond_to_basic_request() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/echo"), + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/echo"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -47,8 +24,8 @@ public async Task Server_should_respond_to_basic_request() [Fact(Timeout = 15000)] public async Task Server_should_respond_to_text_request() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/text"), + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/text"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -59,8 +36,8 @@ public async Task Server_should_respond_to_text_request() [Fact(Timeout = 15000)] public async Task Server_should_return_correct_content_type() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/text"), + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/text"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -71,8 +48,8 @@ public async Task Server_should_return_correct_content_type() [Fact(Timeout = 15000)] public async Task Server_should_return_404_for_unregistered_route() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/nonexistent"), + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/nonexistent"), CancellationToken); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); @@ -81,8 +58,8 @@ public async Task Server_should_return_404_for_unregistered_route() [Fact(Timeout = 15000)] public async Task Server_should_stream_sse_events() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/events"), + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/events"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); diff --git a/src/TurboHTTP.IntegrationTests.Server/Streaming/RawStreamingSpec.cs b/src/TurboHTTP.IntegrationTests.Server/Streaming/RawStreamingSpec.cs index b310bc822..26a3d9994 100644 --- a/src/TurboHTTP.IntegrationTests.Server/Streaming/RawStreamingSpec.cs +++ b/src/TurboHTTP.IntegrationTests.Server/Streaming/RawStreamingSpec.cs @@ -1,73 +1,21 @@ using System.Net; -using System.Text; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Servus.Akka.Transport; using TurboHTTP.IntegrationTests.Server.Shared; -using TurboHTTP.Server; namespace TurboHTTP.IntegrationTests.Server.Streaming; -public sealed class RawStreamingSpec : ServerSpecBase +public sealed class RawStreamingSpec(TurboServerFixture server) : IDisposable { - protected override void ConfigureServer(WebApplicationBuilder builder, ushort port) - { - builder.Host.UseTurboHttp(options => - { - options.Bind(new TcpListenerOptions { Host = "127.0.0.1", Port = port }); - }); - } + private readonly HttpClient _client = server.CreateClient(); - protected override void ConfigureEndpoints(WebApplication app) - { - app.MapGet("/stream-bytes", () => - { - var chunks = new[] - { - new byte[] { 1, 2, 3 }, - new byte[] { 4, 5, 6 }, - new byte[] { 7, 8, 9 } - }; - return Results.Stream(async stream => - { - foreach (var chunk in chunks) - { - await stream.WriteAsync(chunk); - } - }, "application/octet-stream"); - }); + private static CancellationToken CancellationToken => TestContext.Current.CancellationToken; - app.MapGet("/stream-text", () => - { - var lines = new[] { "line1\n", "line2\n", "line3\n" }; - return Results.Stream(async stream => - { - foreach (var line in lines) - { - await stream.WriteAsync(Encoding.UTF8.GetBytes(line)); - } - }, "text/plain"); - }); - - app.MapGet("/stream-large", () => - { - return Results.Stream(async stream => - { - var chunk = new byte[1024]; - Array.Fill(chunk, (byte)0xAB); - for (var i = 0; i < 100; i++) - { - await stream.WriteAsync(chunk); - } - }, "application/octet-stream"); - }); - } + public void Dispose() => _client.Dispose(); [Fact(Timeout = 15000)] public async Task Stream_should_return_all_bytes() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/stream-bytes"), + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/stream-bytes"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -78,8 +26,8 @@ public async Task Stream_should_return_all_bytes() [Fact(Timeout = 15000)] public async Task Stream_should_set_custom_content_type() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/stream-text"), + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/stream-text"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -91,8 +39,8 @@ public async Task Stream_should_set_custom_content_type() [Fact(Timeout = 30000)] public async Task Stream_should_handle_large_payload() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/stream-large"), + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/stream-large"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); diff --git a/src/TurboHTTP.IntegrationTests.Server/Streaming/ResponseBodySpec.cs b/src/TurboHTTP.IntegrationTests.Server/Streaming/ResponseBodySpec.cs index e86b73bf3..68a181936 100644 --- a/src/TurboHTTP.IntegrationTests.Server/Streaming/ResponseBodySpec.cs +++ b/src/TurboHTTP.IntegrationTests.Server/Streaming/ResponseBodySpec.cs @@ -1,56 +1,21 @@ using System.Net; -using System.Text; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Servus.Akka.Transport; using TurboHTTP.IntegrationTests.Server.Shared; -using TurboHTTP.Server; namespace TurboHTTP.IntegrationTests.Server.Streaming; -public sealed class ResponseBodySpec : ServerSpecBase +public sealed class ResponseBodySpec(TurboServerFixture server) : IDisposable { - protected override void ConfigureServer(WebApplicationBuilder builder, ushort port) - { - builder.Host.UseTurboHttp(options => - { - options.Bind(new TcpListenerOptions { Host = "127.0.0.1", Port = port }); - }); - } - - protected override void ConfigureEndpoints(WebApplication app) - { - app.MapGet("/stream-no-cl", () => - { - var chunks = new[] { "chunk1", "chunk2", "chunk3" }; - return Results.Stream(async stream => - { - foreach (var chunk in chunks) - { - await stream.WriteAsync(Encoding.UTF8.GetBytes(chunk)); - } - }, "text/plain"); - }); - - app.MapGet("/with-cl", (HttpContext ctx) => - { - var body = Encoding.UTF8.GetBytes("exact-length-body"); - ctx.Response.StatusCode = 200; - ctx.Response.ContentType = "text/plain"; - ctx.Response.ContentLength = body.Length; - return ctx.Response.Body.WriteAsync(body).AsTask(); - }); + private readonly HttpClient _client = server.CreateClient(); - app.MapGet("/no-content", () => Results.NoContent()); + private static CancellationToken CancellationToken => TestContext.Current.CancellationToken; - app.MapGet("/not-modified", () => Results.StatusCode(304)); - } + public void Dispose() => _client.Dispose(); [Fact(Timeout = 15000)] public async Task Streaming_response_without_content_length_should_deliver_all_chunks() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/stream-no-cl"), + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/stream-no-cl"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -61,8 +26,8 @@ public async Task Streaming_response_without_content_length_should_deliver_all_c [Fact(Timeout = 15000)] public async Task Streaming_response_without_content_length_should_set_content_type() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/stream-no-cl"), + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/stream-no-cl"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -73,8 +38,8 @@ public async Task Streaming_response_without_content_length_should_set_content_t [Fact(Timeout = 15000)] public async Task Response_with_content_length_should_return_exact_body() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/with-cl"), + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/with-cl"), CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -86,8 +51,8 @@ public async Task Response_with_content_length_should_return_exact_body() [Fact(Timeout = 15000)] public async Task NoContent_204_should_have_empty_body() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/no-content"), + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/no-content"), CancellationToken); Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); @@ -98,8 +63,8 @@ public async Task NoContent_204_should_have_empty_body() [Fact(Timeout = 15000)] public async Task NotModified_304_should_have_empty_body() { - var response = await Client.GetAsync( - new Uri($"http://127.0.0.1:{Port}/not-modified"), + var response = await _client.GetAsync( + new Uri($"http://127.0.0.1:{server.Port}/not-modified"), CancellationToken); Assert.Equal((HttpStatusCode)304, response.StatusCode); diff --git a/src/TurboHTTP.IntegrationTests.Server/xunit.runner.json b/src/TurboHTTP.IntegrationTests.Server/xunit.runner.json index 0967ef424..4c6a0fdf5 100644 --- a/src/TurboHTTP.IntegrationTests.Server/xunit.runner.json +++ b/src/TurboHTTP.IntegrationTests.Server/xunit.runner.json @@ -1 +1,4 @@ -{} +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "parallelizeTestCollections": true +} diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11ServerDecoderSecuritySpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11ServerDecoderSecuritySpec.cs index 3bdcf51f4..a57bb6142 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11ServerDecoderSecuritySpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11ServerDecoderSecuritySpec.cs @@ -114,9 +114,13 @@ public void Feed_should_parse_chunked_request_body() "0\r\n\r\n"; var bytes = Encoding.ASCII.GetBytes(request); - var outcome = decoder.Feed(bytes, out _); + var outcome = decoder.Feed(bytes, out var consumed); - Assert.Equal(DecodeOutcome.Complete, outcome); + Assert.Equal(DecodeOutcome.HeadersReady, outcome); + + var bodyOutcome = decoder.Feed(bytes.AsSpan(consumed), out _); + + Assert.Equal(DecodeOutcome.Complete, bodyOutcome); } [Fact(Timeout = 5000)] @@ -132,9 +136,13 @@ public void Feed_should_accept_chunk_size_with_leading_zeros() "0\r\n\r\n"; var bytes = Encoding.ASCII.GetBytes(request); - var outcome = decoder.Feed(bytes, out _); + var outcome = decoder.Feed(bytes, out var consumed); - Assert.Equal(DecodeOutcome.Complete, outcome); + Assert.Equal(DecodeOutcome.HeadersReady, outcome); + + var bodyOutcome = decoder.Feed(bytes.AsSpan(consumed), out _); + + Assert.Equal(DecodeOutcome.Complete, bodyOutcome); } [Fact(Timeout = 5000)] diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Stages/Http20ConnectionStageSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Stages/Http20ConnectionStageSpec.cs index 28dee3972..81c5bc600 100644 --- a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Stages/Http20ConnectionStageSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Stages/Http20ConnectionStageSpec.cs @@ -263,7 +263,7 @@ public async Task Http20ConnectionStage_should_complete_on_goaway_with_no_inflig serverSubscription.SendComplete(); // Stage completes when server upstream finishes - networkSub.ExpectComplete(); + networkSub.ExpectComplete(TestContext.Current.CancellationToken); } [Fact(Timeout = 10_000)] @@ -305,6 +305,6 @@ public async Task Http20ConnectionStage_should_complete_when_app_upstream_finish appSubscription.SendComplete(); // Stage should complete - responseSub.ExpectComplete(); + responseSub.ExpectComplete(TestContext.Current.CancellationToken); } } \ No newline at end of file diff --git a/src/TurboHTTP.Tests/Streams/Stages/Server/ResponseDispatcherHubSpec.cs b/src/TurboHTTP.Tests/Streams/Stages/Server/ResponseDispatcherHubSpec.cs new file mode 100644 index 000000000..432cf7386 --- /dev/null +++ b/src/TurboHTTP.Tests/Streams/Stages/Server/ResponseDispatcherHubSpec.cs @@ -0,0 +1,98 @@ +using Akka.Streams; +using Akka.Streams.Dsl; +using Microsoft.AspNetCore.Http.Features; +using TurboHTTP.Server.Context.Features; +using TurboHTTP.Streams.Stages.Server; +using TurboHTTP.Tests.Shared; + +namespace TurboHTTP.Tests.Streams.Stages.Server; + +public sealed class ResponseDispatcherHubSpec : StreamTestBase +{ + [Fact(Timeout = 5000)] + public async Task ResponseDispatcherHub_should_dispatch_to_correct_connection() + { + var hub = new ResponseDispatcherHub(); + + var fc1 = new FeatureCollection(); + fc1.Set(new ConnectionRoutingFeature { ConnectionId = 1 }); + + var fc2 = new FeatureCollection(); + fc2.Set(new ConnectionRoutingFeature { ConnectionId = 2 }); + + // Create a delayed source to give subscribers time to register + // Cast to IFeatureCollection to avoid type inference issues + var items = new IFeatureCollection[] { fc1, fc2 }; + var dispatcher = Source.From(items) + .Delay(TimeSpan.FromMilliseconds(100), DelayOverflowStrategy.Backpressure) + .ToMaterialized((IGraph, IResponseDispatcher>)hub, Keep.Right) + .Run(Materializer); + + // Subscribe to connections + var source1 = dispatcher.Subscribe(1); + var source2 = dispatcher.Subscribe(2); + + // Now collect results + var collectTask1 = source1.RunWith(Sink.Seq(), Materializer); + var collectTask2 = source2.RunWith(Sink.Seq(), Materializer); + + var results1 = await collectTask1; + var results2 = await collectTask2; + + Assert.Single(results1); + Assert.Equal(1, results1[0].Get()?.ConnectionId); + + Assert.Single(results2); + Assert.Equal(2, results2[0].Get()?.ConnectionId); + } + + [Fact(Timeout = 5000)] + public async Task ResponseDispatcherHub_should_drop_unroutable_responses() + { + var hub = new ResponseDispatcherHub(); + + var fcUnroutable = new FeatureCollection(); + fcUnroutable.Set(new ConnectionRoutingFeature { ConnectionId = 999 }); + + var fcRoutable = new FeatureCollection(); + fcRoutable.Set(new ConnectionRoutingFeature { ConnectionId = 1 }); + + var items = new IFeatureCollection[] { fcUnroutable, fcRoutable }; + var dispatcher = Source.From(items) + .Delay(TimeSpan.FromMilliseconds(100), DelayOverflowStrategy.Backpressure) + .ToMaterialized((IGraph, IResponseDispatcher>)hub, Keep.Right) + .Run(Materializer); + + var source1 = dispatcher.Subscribe(1); + var collectTask = source1.RunWith(Sink.Seq(), Materializer); + + var results = await collectTask; + + Assert.Single(results); + Assert.Equal(1, results[0].Get()?.ConnectionId); + } + + [Fact(Timeout = 5000)] + public async Task ResponseDispatcherHub_should_complete_sources_on_upstream_finish() + { + var hub = new ResponseDispatcherHub(); + + // Emit an unroutable item (won't be delivered to subscriber) + // This gives time for registration while still testing completion + var noMatch = new FeatureCollection(); + noMatch.Set(new ConnectionRoutingFeature { ConnectionId = 999 }); + var items = new IFeatureCollection[] { noMatch }; + + var dispatcher = Source.From(items) + .Delay(TimeSpan.FromMilliseconds(100), DelayOverflowStrategy.Backpressure) + .ToMaterialized((IGraph, IResponseDispatcher>)hub, Keep.Right) + .Run(Materializer); + + var source1 = dispatcher.Subscribe(1); + var collectTask = source1.RunWith(Sink.Seq(), Materializer); + + var results = await collectTask; + + Assert.Empty(results); + } +} diff --git a/src/TurboHTTP/Protocol/BodyHandle.cs b/src/TurboHTTP/Protocol/BodyHandle.cs index 0b130f29e..046a2e220 100644 --- a/src/TurboHTTP/Protocol/BodyHandle.cs +++ b/src/TurboHTTP/Protocol/BodyHandle.cs @@ -4,7 +4,9 @@ namespace TurboHTTP.Protocol; internal sealed class BodyHandle(long maxBodySize) : IDisposable { - private readonly Pipe _pipe = new(); + private static readonly PipeOptions NoPausePipeOptions = new(pauseWriterThreshold: 0); + + private readonly Pipe _pipe = new(NoPausePipeOptions); private long _totalBytes; private bool _completed; diff --git a/src/TurboHTTP/Protocol/LineBased/Body/BodyEncoderFactory.cs b/src/TurboHTTP/Protocol/LineBased/Body/BodyEncoderFactory.cs index 46fc1fa5c..b96d4615a 100644 --- a/src/TurboHTTP/Protocol/LineBased/Body/BodyEncoderFactory.cs +++ b/src/TurboHTTP/Protocol/LineBased/Body/BodyEncoderFactory.cs @@ -4,7 +4,7 @@ namespace TurboHTTP.Protocol.LineBased.Body; internal static class BodyEncoderFactory { - public static IBodyEncoder? Create(Stream? bodyStream, long? contentLength, Version httpVersion) + public static IBodyEncoder? Create(Stream? bodyStream, long? contentLength, Version httpVersion, int chunkSize = 16 * 1024) { if (bodyStream is null) { @@ -18,9 +18,9 @@ internal static class BodyEncoderFactory if (contentLength is not null) { - return new ContentLengthStreamedBodyEncoder(); + return new ContentLengthStreamedBodyEncoder(chunkSize); } - return new ChunkedBodyEncoder(); + return new ChunkedBodyEncoder(chunkSize); } } diff --git a/src/TurboHTTP/Protocol/LineBased/Body/ChunkedBodyEncoder.cs b/src/TurboHTTP/Protocol/LineBased/Body/ChunkedBodyEncoder.cs index fc738a6d6..34b15da00 100644 --- a/src/TurboHTTP/Protocol/LineBased/Body/ChunkedBodyEncoder.cs +++ b/src/TurboHTTP/Protocol/LineBased/Body/ChunkedBodyEncoder.cs @@ -16,14 +16,13 @@ public ChunkedBodyEncoder(int chunkSize = 16 * 1024) public void Start(Stream bodyStream, IActorRef stageActor) { - _ = DrainAsync(new StreamContent(bodyStream), stageActor, _cts.Token); + _ = DrainAsync(bodyStream, stageActor, _cts.Token); } - private async Task DrainAsync(HttpContent content, IActorRef stageActor, CancellationToken ct) + private async Task DrainAsync(Stream stream, IActorRef stageActor, CancellationToken ct) { try { - var stream = await content.ReadAsStreamAsync(ct).ConfigureAwait(false); var dataBuffer = new byte[_chunkSize]; while (true) diff --git a/src/TurboHTTP/Protocol/LineBased/Body/ContentLengthBufferedBodyEncoder.cs b/src/TurboHTTP/Protocol/LineBased/Body/ContentLengthBufferedBodyEncoder.cs index 369ea72ca..e8a606901 100644 --- a/src/TurboHTTP/Protocol/LineBased/Body/ContentLengthBufferedBodyEncoder.cs +++ b/src/TurboHTTP/Protocol/LineBased/Body/ContentLengthBufferedBodyEncoder.cs @@ -9,17 +9,19 @@ internal sealed class ContentLengthBufferedBodyEncoder : IBodyEncoder public void Start(Stream bodyStream, IActorRef stageActor) { - _ = DrainAsync(new StreamContent(bodyStream), stageActor, _cts.Token); + _ = DrainAsync(bodyStream, stageActor, _cts.Token); } - private static async Task DrainAsync(HttpContent content, IActorRef stageActor, CancellationToken ct) + private static async Task DrainAsync(Stream stream, IActorRef stageActor, CancellationToken ct) { try { - var bytes = await content.ReadAsByteArrayAsync(ct).ConfigureAwait(false); - var owner = MemoryPool.Shared.Rent(bytes.Length); - bytes.CopyTo(owner.Memory.Span); - stageActor.Tell(new OutboundBodyChunk(owner, bytes.Length)); + using var ms = new MemoryStream(); + await stream.CopyToAsync(ms, ct).ConfigureAwait(false); + var length = (int)ms.Length; + var owner = MemoryPool.Shared.Rent(length); + ms.GetBuffer().AsSpan(0, length).CopyTo(owner.Memory.Span); + stageActor.Tell(new OutboundBodyChunk(owner, length)); stageActor.Tell(new OutboundBodyComplete()); } catch (Exception ex) diff --git a/src/TurboHTTP/Protocol/LineBased/Body/ContentLengthStreamedBodyEncoder.cs b/src/TurboHTTP/Protocol/LineBased/Body/ContentLengthStreamedBodyEncoder.cs index 3afa0e26e..00204ac33 100644 --- a/src/TurboHTTP/Protocol/LineBased/Body/ContentLengthStreamedBodyEncoder.cs +++ b/src/TurboHTTP/Protocol/LineBased/Body/ContentLengthStreamedBodyEncoder.cs @@ -15,14 +15,13 @@ public ContentLengthStreamedBodyEncoder(int chunkSize = 16 * 1024) public void Start(Stream bodyStream, IActorRef stageActor) { - _ = DrainAsync(new StreamContent(bodyStream), stageActor, _cts.Token); + _ = DrainAsync(bodyStream, stageActor, _cts.Token); } - private async Task DrainAsync(HttpContent content, IActorRef stageActor, CancellationToken ct) + private async Task DrainAsync(Stream stream, IActorRef stageActor, CancellationToken ct) { try { - var stream = await content.ReadAsStreamAsync(ct).ConfigureAwait(false); while (true) { var owner = MemoryPool.Shared.Rent(_chunkSize); diff --git a/src/TurboHTTP/Protocol/Multiplexed/Body/BufferedBodyEncoder.cs b/src/TurboHTTP/Protocol/Multiplexed/Body/BufferedBodyEncoder.cs index 9bb32c24f..4d8dfabc7 100644 --- a/src/TurboHTTP/Protocol/Multiplexed/Body/BufferedBodyEncoder.cs +++ b/src/TurboHTTP/Protocol/Multiplexed/Body/BufferedBodyEncoder.cs @@ -6,16 +6,18 @@ internal sealed class BufferedBodyEncoder : IBodyEncoder { private readonly CancellationTokenSource _cts = new(); - public void Start(Stream bodyStream, Action onMessage) => _ = DrainAsync(new StreamContent(bodyStream), onMessage, _cts.Token); + public void Start(Stream bodyStream, Action onMessage) => _ = DrainAsync(bodyStream, onMessage, _cts.Token); - private static async Task DrainAsync(HttpContent content, Action onMessage, CancellationToken ct) + private static async Task DrainAsync(Stream stream, Action onMessage, CancellationToken ct) { try { - var bytes = await content.ReadAsByteArrayAsync(ct).ConfigureAwait(false); - var owner = MemoryPool.Shared.Rent(bytes.Length); - bytes.CopyTo(owner.Memory.Span); - onMessage(new OutboundBodyChunk(owner, bytes.Length)); + using var ms = new MemoryStream(); + await stream.CopyToAsync(ms, ct).ConfigureAwait(false); + var length = (int)ms.Length; + var owner = MemoryPool.Shared.Rent(length); + ms.GetBuffer().AsSpan(0, length).CopyTo(owner.Memory.Span); + onMessage(new OutboundBodyChunk(owner, length)); onMessage(new OutboundBodyComplete()); } catch (Exception ex) diff --git a/src/TurboHTTP/Protocol/Multiplexed/Body/MultiplexedBodyEncoderFactory.cs b/src/TurboHTTP/Protocol/Multiplexed/Body/MultiplexedBodyEncoderFactory.cs index 100360545..7c9019061 100644 --- a/src/TurboHTTP/Protocol/Multiplexed/Body/MultiplexedBodyEncoderFactory.cs +++ b/src/TurboHTTP/Protocol/Multiplexed/Body/MultiplexedBodyEncoderFactory.cs @@ -2,7 +2,7 @@ namespace TurboHTTP.Protocol.Multiplexed.Body; internal static class BodyEncoderFactory { - public static IBodyEncoder? Create(Stream? bodyStream, long? contentLength) + public static IBodyEncoder? Create(Stream? bodyStream, long? contentLength, int chunkSize = 16 * 1024) { if (bodyStream is null) { @@ -14,6 +14,6 @@ internal static class BodyEncoderFactory return new BufferedBodyEncoder(); } - return new StreamingBodyEncoder(); + return new StreamingBodyEncoder(chunkSize); } } diff --git a/src/TurboHTTP/Protocol/Multiplexed/Body/StreamingBodyEncoder.cs b/src/TurboHTTP/Protocol/Multiplexed/Body/StreamingBodyEncoder.cs index a32e55af7..db1c87776 100644 --- a/src/TurboHTTP/Protocol/Multiplexed/Body/StreamingBodyEncoder.cs +++ b/src/TurboHTTP/Protocol/Multiplexed/Body/StreamingBodyEncoder.cs @@ -12,13 +12,12 @@ public StreamingBodyEncoder(int chunkSize = 16 * 1024) _chunkSize = chunkSize; } - public void Start(Stream bodyStream, Action onMessage) => _ = DrainAsync(new StreamContent(bodyStream), onMessage, _cts.Token); + public void Start(Stream bodyStream, Action onMessage) => _ = DrainAsync(bodyStream, onMessage, _cts.Token); - private async Task DrainAsync(HttpContent content, Action onMessage, CancellationToken ct) + private async Task DrainAsync(Stream stream, Action onMessage, CancellationToken ct) { try { - var stream = await content.ReadAsStreamAsync(ct).ConfigureAwait(false); while (true) { var owner = MemoryPool.Shared.Rent(_chunkSize); diff --git a/src/TurboHTTP/Protocol/Syntax/Http10/Server/Http10ServerStateMachine.cs b/src/TurboHTTP/Protocol/Syntax/Http10/Server/Http10ServerStateMachine.cs index d9a617e5d..2aab32d92 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http10/Server/Http10ServerStateMachine.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http10/Server/Http10ServerStateMachine.cs @@ -102,7 +102,7 @@ public void OnResponse(IFeatureCollection features) if (responseBody is TurboHttpResponseBodyFeature turboBody) { var bodyStream = turboBody.GetResponseStream(); - var encoder = BodyEncoderFactory.Create(bodyStream, null, HttpVersion.Version10); + var encoder = BodyEncoderFactory.Create(bodyStream, null, HttpVersion.Version10, _serverOptions.ResponseBodyChunkSize); if (encoder is not null) { _activeBodyEncoder = encoder; diff --git a/src/TurboHTTP/Protocol/Syntax/Http11/Server/Http11ServerDecoder.cs b/src/TurboHTTP/Protocol/Syntax/Http11/Server/Http11ServerDecoder.cs index 889454a52..dc9d4d7e4 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http11/Server/Http11ServerDecoder.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http11/Server/Http11ServerDecoder.cs @@ -73,7 +73,21 @@ public DecodeOutcome Feed(ReadOnlySpan data, out int consumed) _options.Shared.BufferPool, _options.Shared.MaxBufferedBodySize, _options.Shared.MaxStreamedBodySize); + + if (_bodyDecoder.IsComplete) + { + _phase = Phase.Done; + consumed = pos; + return DecodeOutcome.Complete; + } + _phase = Phase.Body; + + if (!_bodyDecoder.IsBuffered) + { + consumed = pos; + return DecodeOutcome.HeadersReady; + } } if (_phase == Phase.Body) diff --git a/src/TurboHTTP/Protocol/Syntax/Http11/Server/Http11ServerStateMachine.cs b/src/TurboHTTP/Protocol/Syntax/Http11/Server/Http11ServerStateMachine.cs index 9fb7c777f..200e9dd66 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http11/Server/Http11ServerStateMachine.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http11/Server/Http11ServerStateMachine.cs @@ -20,11 +20,14 @@ internal sealed class Http11ServerStateMachine : IServerStateMachine private readonly TimeSpan _keepAliveTimeout; private readonly TimeSpan _requestHeadersTimeout; + private readonly TimeSpan _bodyConsumptionTimeout; + private int _requestsPipelined; private int _pendingResponseCount; private bool _outboundBodyPending; private bool _requestHeadersTimerActive; private bool _draining; + private bool _bodyStreaming; private readonly TurboServerOptions _serverOptions; public bool CanAcceptResponse => !_outboundBodyPending && _pendingResponseCount > 0; @@ -36,6 +39,7 @@ public Http11ServerStateMachine(TurboServerOptions options, IServerStageOperatio _ops = ops ?? throw new ArgumentNullException(nameof(ops)); ArgumentNullException.ThrowIfNull(options); _serverOptions = options; + _bodyConsumptionTimeout = options.BodyConsumptionTimeout; var shared = SharedHttpOptions.Default with { @@ -85,31 +89,43 @@ public void DecodeClientData(ITransportInbound data) var span = buffer.Memory.Span; var pos = 0; - if (_draining && _decoder.CurrentBodyDecoder is { } bodyDecoder) + if (_draining && _decoder.CurrentBodyDecoder is { } drainingDecoder) { - var drained = bodyDecoder.Drain(span[pos..]); + var drained = drainingDecoder.Drain(span[pos..]); pos += drained; - if (bodyDecoder.IsComplete) + if (drainingDecoder.IsComplete) { _draining = false; + _ops.OnCancelTimer("body-consumption"); + _decoder.Reset(); + } + } + else if (_bodyStreaming && _decoder.CurrentBodyDecoder is { } streamingDecoder) + { + var done = streamingDecoder.Feed(span[pos..], out var bConsumed); + pos += bConsumed; + + if (done) + { + _bodyStreaming = false; _decoder.Reset(); } } // Schedule request headers timeout if not already active - if (!_requestHeadersTimerActive && _pendingResponseCount == 0 && _requestHeadersTimeout > TimeSpan.Zero) + if (!_requestHeadersTimerActive && _pendingResponseCount == 0 && !_bodyStreaming && _requestHeadersTimeout > TimeSpan.Zero) { _ops.OnScheduleTimer("request-headers", _requestHeadersTimeout); _requestHeadersTimerActive = true; } - while (pos < span.Length) + while (pos < span.Length && !_bodyStreaming) { var outcome = _decoder.Feed(span[pos..], out var consumed); pos += consumed; - if (outcome != DecodeOutcome.Complete) + if (outcome == DecodeOutcome.NeedMore) { break; } @@ -134,7 +150,7 @@ public void DecodeClientData(ITransportInbound data) } var feature = _decoder.GetRequestFeature(); - var hasBody = feature.Body != Stream.Null; + var hasBody = outcome == DecodeOutcome.HeadersReady || feature.Body != Stream.Null; var features = FeatureCollectionFactory.Create(feature, hasBody, _ops.Services, _ops.ConnectionFeature, _ops.TlsHandshakeFeature, _serverOptions.Limits.MaxRequestBodySize); @@ -151,6 +167,26 @@ public void DecodeClientData(ITransportInbound data) _pendingResponseCount++; _ops.OnRequest(features); + + if (outcome == DecodeOutcome.HeadersReady) + { + _bodyStreaming = true; + + if (pos < span.Length) + { + var bodyDone = _decoder.CurrentBodyDecoder!.Feed(span[pos..], out var bConsumed); + pos += bConsumed; + if (bodyDone) + { + _bodyStreaming = false; + _decoder.Reset(); + continue; + } + } + + break; + } + _decoder.Reset(); } } @@ -201,9 +237,19 @@ public void OnResponse(IFeatureCollection features) return; } - if (!_draining && _decoder.CurrentBodyDecoder is { IsComplete: false }) + if (_decoder.CurrentBodyDecoder is { IsComplete: false }) { + if (_bodyStreaming) + { + _bodyStreaming = false; + } + _draining = true; + + if (_bodyConsumptionTimeout > TimeSpan.Zero) + { + _ops.OnScheduleTimer("body-consumption", _bodyConsumptionTimeout); + } } if (responseBody is TurboHttpResponseBodyFeature turboBody) @@ -211,7 +257,7 @@ public void OnResponse(IFeatureCollection features) _outboundBodyPending = true; var bodyStream = turboBody.GetResponseStream(); - var encoder = BodyEncoderFactory.Create(bodyStream, contentLength, HttpVersion.Version11); + var encoder = BodyEncoderFactory.Create(bodyStream, contentLength, HttpVersion.Version11, _serverOptions.ResponseBodyChunkSize); if (encoder is not null) { _encoder.SetActiveBodyEncoder(encoder); @@ -235,15 +281,18 @@ public void OnTimerFired(string name) { if (name == "keep-alive") { - // Keep-alive timeout expired, close the connection ShouldComplete = true; } else if (name == "request-headers") { - // Request headers timeout expired before headers were fully received _requestHeadersTimerActive = false; ShouldComplete = true; } + else if (name == "body-consumption") + { + _draining = false; + ShouldComplete = true; + } } public void OnBodyMessage(object msg) @@ -348,5 +397,6 @@ public void Cleanup() } _ops.OnCancelTimer("keep-alive"); + _ops.OnCancelTimer("body-consumption"); } } \ No newline at end of file diff --git a/src/TurboHTTP/Protocol/Syntax/Http2/Server/Http2ServerSessionManager.cs b/src/TurboHTTP/Protocol/Syntax/Http2/Server/Http2ServerSessionManager.cs index ad8faf934..d0bac6fb7 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http2/Server/Http2ServerSessionManager.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http2/Server/Http2ServerSessionManager.cs @@ -24,6 +24,8 @@ internal sealed class Http2ServerSessionManager private readonly FlowController _flow; private readonly StreamTracker _tracker; private readonly long _maxRequestBodySize; + private readonly int _responseBodyChunkSize; + private readonly TimeSpan _bodyConsumptionTimeout; private readonly int _initialStreamWindowSize; private readonly Dictionary _streams = new(); @@ -51,6 +53,8 @@ public Http2ServerSessionManager( _flow = new FlowController(options.Http2.InitialConnectionWindowSize, options.Http2.InitialStreamWindowSize); _tracker = new StreamTracker(initialNextStreamId: 1, decoderOptions.MaxConcurrentStreams); _maxRequestBodySize = options.Http2.MaxRequestBodySize; + _responseBodyChunkSize = options.ResponseBodyChunkSize; + _bodyConsumptionTimeout = options.BodyConsumptionTimeout; _initialStreamWindowSize = options.Http2.InitialStreamWindowSize; var statePoolCapacity = Math.Min( @@ -160,6 +164,11 @@ public void OnResponse(IFeatureCollection features) state.SetFeatures(features); + if (state.HasBodyDecoder && _bodyConsumptionTimeout > TimeSpan.Zero) + { + _ops.OnScheduleTimer(string.Concat("body-consumption:", streamId.ToString()), _bodyConsumptionTimeout); + } + var responseFeature = features.Get(); var responseBody = features.Get(); var contentLength = ExtractContentLength(responseFeature); @@ -184,7 +193,7 @@ public void OnResponse(IFeatureCollection features) } var bodyStream = turboBody.GetResponseStream(); - var encoder = BodyEncoderFactory.Create(bodyStream, contentLength); + var encoder = BodyEncoderFactory.Create(bodyStream, contentLength, _responseBodyChunkSize); if (encoder is null) { CloseStream(streamId); @@ -455,6 +464,11 @@ private void HandleDataFrame(DataFrame data) return; } + if (data.EndStream) + { + _ops.OnCancelTimer(string.Concat("body-consumption:", streamId.ToString())); + } + if (!data.Data.IsEmpty) { if (!_bodyRateStates.TryGetValue(streamId, out var rateState)) @@ -606,6 +620,7 @@ private StreamState GetOrCreateStreamState(int streamId) private void CloseStream(int streamId) { _bodyRateStates.Remove(streamId); + _ops.OnCancelTimer(string.Concat("body-consumption:", streamId.ToString())); if (_streams.TryGetValue(streamId, out var state)) { diff --git a/src/TurboHTTP/Protocol/Syntax/Http2/Server/Http2ServerStateMachine.cs b/src/TurboHTTP/Protocol/Syntax/Http2/Server/Http2ServerStateMachine.cs index 3ecf4c7a4..2fd894131 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http2/Server/Http2ServerStateMachine.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http2/Server/Http2ServerStateMachine.cs @@ -12,6 +12,7 @@ internal sealed class Http2ServerStateMachine : IServerStateMachine private const string HeadersTimeoutPrefix = "headers-timeout:"; private const string KeepAliveTimeout = "keep-alive-timeout"; private const string BodyRateCheck = "body-rate-check:"; + private const string BodyConsumptionPrefix = "body-consumption:"; private readonly IServerStageOperations _ops; private readonly Http2ServerSessionManager _sessionManager; @@ -133,6 +134,15 @@ public void OnTimerFired(string name) if (name == BodyRateCheck) { _sessionManager.CheckBodyRates(_minBodyDataRate, _bodyRateGracePeriod); + return; + } + + if (name.StartsWith(BodyConsumptionPrefix)) + { + if (int.TryParse(name.AsSpan(BodyConsumptionPrefix.Length), out var consumptionStreamId)) + { + _sessionManager.EmitRstStream(consumptionStreamId, Http2ErrorCode.Cancel); + } } } diff --git a/src/TurboHTTP/Protocol/Syntax/Http3/Server/Http3ServerSessionManager.cs b/src/TurboHTTP/Protocol/Syntax/Http3/Server/Http3ServerSessionManager.cs index d4fc1b4e0..cbd4e581a 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http3/Server/Http3ServerSessionManager.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http3/Server/Http3ServerSessionManager.cs @@ -24,6 +24,8 @@ internal sealed class Http3ServerSessionManager private readonly Http3ServerEncoderOptions _encoderOptions; private readonly Http3ServerDecoderOptions _decoderOptions; private readonly long _maxRequestBodySize; + private readonly int _responseBodyChunkSize; + private readonly TimeSpan _bodyConsumptionTimeout; private readonly Dictionary _streams = new(); private readonly StackStreamStatePool _statePool; @@ -38,12 +40,16 @@ public Http3ServerSessionManager( Http3ServerEncoderOptions encoderOptions, Http3ServerDecoderOptions decoderOptions, IServerStageOperations ops, - long maxRequestBodySize = 30 * 1024 * 1024) + long maxRequestBodySize = 30 * 1024 * 1024, + int responseBodyChunkSize = 16 * 1024, + TimeSpan? bodyConsumptionTimeout = null) { _encoderOptions = encoderOptions; _decoderOptions = decoderOptions; _ops = ops ?? throw new ArgumentNullException(nameof(ops)); _maxRequestBodySize = maxRequestBodySize; + _responseBodyChunkSize = responseBodyChunkSize; + _bodyConsumptionTimeout = bodyConsumptionTimeout ?? TimeSpan.FromSeconds(30); _tableSync = new QpackTableSync( encoderMaxCapacity: 0, @@ -128,6 +134,11 @@ public void OnResponse(IFeatureCollection features) var (_, state) = streamData; + if (state.HasBodyDecoder && _bodyConsumptionTimeout > TimeSpan.Zero) + { + _ops.OnScheduleTimer(string.Concat("body-consumption:", streamId.ToString()), _bodyConsumptionTimeout); + } + var headersFrame = _responseEncoder.EncodeHeaders(features); EmitDataFrame(headersFrame, streamId); @@ -150,7 +161,7 @@ public void OnResponse(IFeatureCollection features) } var bodyStream = turboBody.GetResponseStream(); - var encoder = BodyEncoderFactory.Create(bodyStream, contentLength); + var encoder = BodyEncoderFactory.Create(bodyStream, contentLength, _responseBodyChunkSize); if (encoder is null) { _ops.OnOutbound(new CompleteWrites(streamId)); @@ -453,6 +464,7 @@ private void FlushPendingRequest(long streamId) if (requestFeature is not null) { _ops.OnCancelTimer(string.Concat("headers-timeout:", streamId.ToString())); + _ops.OnCancelTimer(string.Concat("body-consumption:", streamId.ToString())); var hasBody = state.HasBodyDecoder; if (hasBody) @@ -517,6 +529,7 @@ private long GetStreamIdFromFeatures(IFeatureCollection features) private void CloseStream(long streamId) { _bodyRateStates.Remove(streamId); + _ops.OnCancelTimer(string.Concat("body-consumption:", streamId.ToString())); if (_streams.TryGetValue(streamId, out var streamData)) { diff --git a/src/TurboHTTP/Protocol/Syntax/Http3/Server/Http3ServerStateMachine.cs b/src/TurboHTTP/Protocol/Syntax/Http3/Server/Http3ServerStateMachine.cs index 1bd96affb..a265dfb19 100644 --- a/src/TurboHTTP/Protocol/Syntax/Http3/Server/Http3ServerStateMachine.cs +++ b/src/TurboHTTP/Protocol/Syntax/Http3/Server/Http3ServerStateMachine.cs @@ -12,6 +12,7 @@ internal sealed class Http3ServerStateMachine : IServerStateMachine private const string HeadersTimeoutPrefix = "headers-timeout:"; private const string KeepAliveTimeout = "keep-alive-timeout"; private const string BodyRateCheck = "body-rate-check"; + private const string BodyConsumptionPrefix = "body-consumption:"; private readonly IServerStageOperations _ops; private readonly Http3ServerSessionManager _sessionManager; @@ -51,7 +52,8 @@ public Http3ServerStateMachine(TurboServerOptions options, IServerStageOperation MaxFieldSectionSize = options.Http3.MaxHeaderListSize, }; - _sessionManager = new Http3ServerSessionManager(encoderOpts, decoderOpts, ops, options.Http3.MaxRequestBodySize); + _sessionManager = new Http3ServerSessionManager(encoderOpts, decoderOpts, ops, options.Http3.MaxRequestBodySize, + options.ResponseBodyChunkSize, options.BodyConsumptionTimeout); _keepAliveTimeout = options.Http3.KeepAliveTimeout; _requestHeadersTimeout = options.Http3.RequestHeadersTimeout; @@ -132,6 +134,15 @@ public void OnTimerFired(string name) if (name == BodyRateCheck) { _sessionManager.CheckBodyRates(_minBodyDataRate, _bodyRateGracePeriod); + return; + } + + if (name.StartsWith(BodyConsumptionPrefix)) + { + if (long.TryParse(name.AsSpan(BodyConsumptionPrefix.Length), out var consumptionStreamId)) + { + _sessionManager.EmitRstStream(consumptionStreamId, ErrorCode.GeneralProtocolError); + } } } diff --git a/src/TurboHTTP/Server/Context/Features/ConnectionRoutingFeature.cs b/src/TurboHTTP/Server/Context/Features/ConnectionRoutingFeature.cs new file mode 100644 index 000000000..1b77262bf --- /dev/null +++ b/src/TurboHTTP/Server/Context/Features/ConnectionRoutingFeature.cs @@ -0,0 +1,6 @@ +namespace TurboHTTP.Server.Context.Features; + +internal sealed class ConnectionRoutingFeature +{ + public int ConnectionId { get; init; } +} diff --git a/src/TurboHTTP/Server/Context/ITurboFormCollection.cs b/src/TurboHTTP/Server/Context/ITurboFormCollection.cs deleted file mode 100644 index db5ae1286..000000000 --- a/src/TurboHTTP/Server/Context/ITurboFormCollection.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Microsoft.Extensions.Primitives; - -namespace TurboHTTP.Server.Context; - -public interface ITurboFormCollection : IEnumerable> -{ - StringValues this[string key] { get; } - int Count { get; } - ICollection Keys { get; } - bool ContainsKey(string key); - ITurboFormFileCollection Files { get; } -} - -public interface ITurboFormFileCollection : IEnumerable -{ - ITurboFormFile this[int index] { get; } - ITurboFormFile? this[string name] { get; } - int Count { get; } - ITurboFormFile? GetFile(string name); - IReadOnlyList GetFiles(string name); -} diff --git a/src/TurboHTTP/Server/Context/ITurboFormFile.cs b/src/TurboHTTP/Server/Context/ITurboFormFile.cs deleted file mode 100644 index d4948002c..000000000 --- a/src/TurboHTTP/Server/Context/ITurboFormFile.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace TurboHTTP.Server.Context; - -public interface ITurboFormFile -{ - string Name { get; } - string FileName { get; } - string ContentType { get; } - long Length { get; } - Stream OpenReadStream(); - void CopyTo(Stream target); - Task CopyToAsync(Stream target, CancellationToken cancellationToken = default); -} diff --git a/src/TurboHTTP/Server/Context/ITurboHeaderDictionary.cs b/src/TurboHTTP/Server/Context/ITurboHeaderDictionary.cs deleted file mode 100644 index a0bdb9632..000000000 --- a/src/TurboHTTP/Server/Context/ITurboHeaderDictionary.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Microsoft.Extensions.Primitives; - -namespace TurboHTTP.Server.Context; - -public interface ITurboHeaderDictionary : IEnumerable> -{ - StringValues this[string key] { get; set; } - long? ContentLength { get; set; } - int Count { get; } - ICollection Keys { get; } - bool ContainsKey(string key); - bool TryGetValue(string key, out StringValues value); - void Add(string key, StringValues value); - bool Remove(string key); - void Clear(); -} diff --git a/src/TurboHTTP/Server/Context/ITurboQueryCollection.cs b/src/TurboHTTP/Server/Context/ITurboQueryCollection.cs deleted file mode 100644 index 594e845f0..000000000 --- a/src/TurboHTTP/Server/Context/ITurboQueryCollection.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Microsoft.Extensions.Primitives; - -namespace TurboHTTP.Server.Context; - -public interface ITurboQueryCollection : IEnumerable> -{ - StringValues this[string key] { get; } - int Count { get; } - ICollection Keys { get; } - bool ContainsKey(string key); - bool TryGetValue(string key, out StringValues value); -} diff --git a/src/TurboHTTP/Server/Context/ITurboRequestCookieCollection.cs b/src/TurboHTTP/Server/Context/ITurboRequestCookieCollection.cs deleted file mode 100644 index 92e682ac8..000000000 --- a/src/TurboHTTP/Server/Context/ITurboRequestCookieCollection.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace TurboHTTP.Server.Context; - -public interface ITurboRequestCookieCollection : IEnumerable> -{ - string? this[string key] { get; } - int Count { get; } - ICollection Keys { get; } - bool ContainsKey(string key); -} diff --git a/src/TurboHTTP/Server/Context/TurboFormCollection.cs b/src/TurboHTTP/Server/Context/TurboFormCollection.cs deleted file mode 100644 index 9e36de12c..000000000 --- a/src/TurboHTTP/Server/Context/TurboFormCollection.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System.Collections; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Primitives; - -namespace TurboHTTP.Server.Context; - -internal sealed class TurboFormCollection(Dictionary fields, IFormFileCollection files) : IFormCollection, ITurboFormCollection -{ - public StringValues this[string key] - => fields.TryGetValue(key, out var value) ? value : StringValues.Empty; - - public int Count => fields.Count; - public ICollection Keys => fields.Keys; - public IFormFileCollection Files { get; } = files; - - public bool ContainsKey(string key) => fields.ContainsKey(key); - public bool TryGetValue(string key, out StringValues value) => fields.TryGetValue(key, out value); - public IEnumerator> GetEnumerator() => fields.GetEnumerator(); - IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); - - ITurboFormFileCollection ITurboFormCollection.Files => (ITurboFormFileCollection)files; -} - -internal sealed class TurboFormFileCollection : IFormFileCollection, ITurboFormFileCollection -{ - private readonly List _files; - - public TurboFormFileCollection(List files) - { - _files = files; - } - - public IFormFile this[int index] => _files[index]; - - public IFormFile? this[string name] => _files.FirstOrDefault(f => - string.Equals(f.Name, name, StringComparison.OrdinalIgnoreCase)); - - public int Count => _files.Count; - public IFormFile? GetFile(string name) => this[name]; - - public IReadOnlyList GetFiles(string name) - => _files.Where(f => string.Equals(f.Name, name, StringComparison.OrdinalIgnoreCase)).ToList(); - - public IEnumerator GetEnumerator() => _files.GetEnumerator(); - IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); - - ITurboFormFile ITurboFormFileCollection.this[int index] => (ITurboFormFile)_files[index]; - - ITurboFormFile? ITurboFormFileCollection.this[string name] => (ITurboFormFile?)_files.FirstOrDefault(f => - string.Equals(f.Name, name, StringComparison.OrdinalIgnoreCase)); - - ITurboFormFile? ITurboFormFileCollection.GetFile(string name) => (ITurboFormFile?)this[name]; - - IReadOnlyList ITurboFormFileCollection.GetFiles(string name) - => _files.Where(f => string.Equals(f.Name, name, StringComparison.OrdinalIgnoreCase)).ToList().Cast().ToList(); - - IEnumerator IEnumerable.GetEnumerator() - => _files.Cast().GetEnumerator(); -} \ No newline at end of file diff --git a/src/TurboHTTP/Server/Context/TurboFormFile.cs b/src/TurboHTTP/Server/Context/TurboFormFile.cs deleted file mode 100644 index 1466a886b..000000000 --- a/src/TurboHTTP/Server/Context/TurboFormFile.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Microsoft.AspNetCore.Http; - -namespace TurboHTTP.Server.Context; - -internal sealed class TurboFormFile : IFormFile, ITurboFormFile -{ - private readonly byte[] _content; - - public TurboFormFile(string name, string fileName, string contentType, byte[] content) - { - Name = name; - FileName = fileName; - ContentType = contentType; - _content = content; - Length = content.Length; - Headers = new HeaderDictionary(); - } - - public string ContentDisposition => string.Concat("form-data; name=\"", Name, "\"; filename=\"", FileName, "\""); - public string ContentType { get; } - public string FileName { get; } - public IHeaderDictionary Headers { get; } - public long Length { get; } - public string Name { get; } - - public void CopyTo(Stream target) => target.Write(_content, 0, _content.Length); - public async Task CopyToAsync(Stream target, CancellationToken cancellationToken = default) - => await target.WriteAsync(_content, cancellationToken); - public Stream OpenReadStream() => new MemoryStream(_content, writable: false); -} diff --git a/src/TurboHTTP/Server/Context/TurboResponseHeaderDictionary.cs b/src/TurboHTTP/Server/Context/TurboResponseHeaderDictionary.cs index fc9779149..3f233445a 100644 --- a/src/TurboHTTP/Server/Context/TurboResponseHeaderDictionary.cs +++ b/src/TurboHTTP/Server/Context/TurboResponseHeaderDictionary.cs @@ -6,7 +6,9 @@ namespace TurboHTTP.Server.Context; -internal sealed class TurboResponseHeaderDictionary : IHeaderDictionary, ITurboHeaderDictionary +public interface ITurboHeaderDictionary : IHeaderDictionary; + +internal sealed class TurboResponseHeaderDictionary : ITurboHeaderDictionary { private readonly Dictionary _headers = new(StringComparer.OrdinalIgnoreCase); @@ -125,4 +127,4 @@ internal void Reset() { _headers.Clear(); } -} +} \ No newline at end of file diff --git a/src/TurboHTTP/Server/Hosting/TurboConfigurationBinder.cs b/src/TurboHTTP/Server/Hosting/TurboConfigurationBinder.cs deleted file mode 100644 index 500f38524..000000000 --- a/src/TurboHTTP/Server/Hosting/TurboConfigurationBinder.cs +++ /dev/null @@ -1,163 +0,0 @@ -using System.Net; -using System.Security.Authentication; -using Microsoft.Extensions.Configuration; - -namespace TurboHTTP.Server.Hosting; - -internal static class TurboConfigurationBinder -{ - public static void Bind(TurboServerOptions options, IConfigurationSection section) - { - if (!section.Exists()) - { - return; - } - - BindHttpsDefaults(options, section.GetSection("HttpsDefaults")); - BindEndpoints(options, section.GetSection("Endpoints")); - } - - private static void BindHttpsDefaults(TurboServerOptions options, IConfigurationSection section) - { - if (!section.Exists()) - { - return; - } - - var sslProtocols = ParseSslProtocols(section["SslProtocols"]); - var handshakeTimeout = ParseTimeSpan(section["HandshakeTimeout"]); - - options.ConfigureHttpsDefaults(https => - { - if (sslProtocols != SslProtocols.None) - { - https.EnabledSslProtocols = sslProtocols; - } - - if (handshakeTimeout.HasValue) - { - https.HandshakeTimeout = handshakeTimeout.Value; - } - }); - } - - private static void BindEndpoints(TurboServerOptions options, IConfigurationSection section) - { - if (!section.Exists()) - { - return; - } - - foreach (var endpoint in section.GetChildren()) - { - var url = endpoint["Url"]; - if (url is null) - { - continue; - } - - var certSection = endpoint.GetSection("Certificate"); - var hasCert = certSection.Exists() && certSection["Path"] is not null; - var hasSslProtocols = endpoint["SslProtocols"] is not null; - var hasProtocols = endpoint["Protocols"] is not null; - - if (!hasCert && !hasSslProtocols && !hasProtocols) - { - options.Urls.Add(url); - continue; - } - - if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) - { - options.Urls.Add(url); - continue; - } - - var host = uri.Host; - IPAddress address; - - if (host == "*" || host == "+") - { - address = IPAddress.Any; - } - else if (IPAddress.TryParse(host, out var parsed)) - { - address = parsed; - } - else if (host.Equals("localhost", StringComparison.OrdinalIgnoreCase)) - { - address = IPAddress.Loopback; - } - else - { - address = IPAddress.Any; - } - - var port = (ushort)uri.Port; - var protocols = ParseHttpProtocols(endpoint["Protocols"]); - var sslProtocols = ParseSslProtocols(endpoint["SslProtocols"]); - - options.Listen(address, port, listen => - { - if (protocols != HttpProtocols.None) - { - listen.Protocols = protocols; - } - - if (uri.Scheme == "https") - { - if (hasCert) - { - listen.UseHttps(certSection["Path"]!, certSection["Password"], https => - { - if (sslProtocols != SslProtocols.None) - { - https.EnabledSslProtocols = sslProtocols; - } - }); - } - else - { - listen.UseHttps(https => - { - if (sslProtocols != SslProtocols.None) - { - https.EnabledSslProtocols = sslProtocols; - } - }); - } - } - }); - } - } - - private static SslProtocols ParseSslProtocols(string? value) - { - if (value is null) - { - return SslProtocols.None; - } - - return Enum.Parse(value, ignoreCase: true); - } - - private static HttpProtocols ParseHttpProtocols(string? value) - { - if (value is null) - { - return HttpProtocols.None; - } - - return Enum.Parse(value, ignoreCase: true); - } - - private static TimeSpan? ParseTimeSpan(string? value) - { - if (value is null) - { - return null; - } - - return TimeSpan.Parse(value); - } -} \ No newline at end of file diff --git a/src/TurboHTTP/Server/TurboServer.cs b/src/TurboHTTP/Server/TurboServer.cs index 1870aeacc..bd72c04f3 100644 --- a/src/TurboHTTP/Server/TurboServer.cs +++ b/src/TurboHTTP/Server/TurboServer.cs @@ -29,6 +29,7 @@ public sealed class TurboServer : IServer private ActorSystem? _system; private bool _ownsSystem; private IActorRef _supervisor = ActorRefs.Nobody; + private IActorRef _pipelineOwner = ActorRefs.Nobody; public TurboServer(IOptions options, ILoggerFactory loggerFactory, IServiceProvider services) { @@ -59,28 +60,24 @@ public async Task StartAsync( var materializer = _system.Materializer(); var parallelism = _options.Http2.MaxConcurrentStreams; - var bridgeFlow = Flow.FromGraph(new ApplicationBridgeStage( + var bridgeFlow = Flow.FromGraph(new SharedBridgeStage( application, parallelism, _options.HandlerTimeout, _options.HandlerGracePeriod)); + _pipelineOwner = _system.ActorOf( + Props.Create(() => new ServerPipelineOwner(bridgeFlow)), + "turbo-pipeline"); + + var ready = await _pipelineOwner.Ask( + new ServerPipelineOwner.Initialize(), + TimeSpan.FromSeconds(10), + cancellationToken); + var resolver = new EndpointResolver(); var resolvedEndpoints = resolver.Resolve(_options); - var addressesFeature = _features.Get()!; - foreach (var endpoint in resolvedEndpoints) - { - var opts = endpoint.Options; - var scheme = (opts is TcpListenerOptions tcp && tcp.ServerCertificate is not null) ? "https" : "http"; - var host = opts.Host ?? "localhost"; - if (host == "0.0.0.0" || host == "::") - { - host = "localhost"; - } - addressesFeature.Addresses.Add(string.Concat(scheme, "://", host, ":", opts.Port.ToString())); - } - var listenerProps = new List(resolvedEndpoints.Count); foreach (var endpoint in resolvedEndpoints) { @@ -88,7 +85,8 @@ public async Task StartAsync( endpoint.Factory, endpoint.Options, _options, - bridgeFlow, + ready.RequestIngress, + ready.Dispatcher, _services, materializer, endpoint.ConnectionLoggingCategory)); @@ -98,38 +96,69 @@ public async Task StartAsync( Props.Create(() => new ServerSupervisorActor()), "turbo-server"); - await _supervisor.Ask( + var listenersReady = await _supervisor.Ask( new ServerSupervisorActor.StartListeners(listenerProps), TimeSpan.FromSeconds(30), cancellationToken); - var cs = CoordinatedShutdown.Get(_system); - - cs.AddTask(CoordinatedShutdown.PhaseBeforeServiceUnbind, "turbo-stop-accepting", () => + var addressesFeature = _features.Get()!; + for (var i = 0; i < resolvedEndpoints.Count; i++) { - _supervisor.Tell(new ServerSupervisorActor.StopAccepting()); - return Task.FromResult(Done.Instance); - }); + var opts = resolvedEndpoints[i].Options; + var scheme = opts is TcpListenerOptions { ServerCertificate: not null } ? "https" : "http"; + var host = opts.Host; + if (host is "0.0.0.0" or "::") + { + host = "localhost"; + } - cs.AddTask(CoordinatedShutdown.PhaseServiceUnbind, "turbo-goaway", () => - { - _supervisor.Tell(new ServerSupervisorActor.BeginDrain(_options.GracefulShutdownTimeout)); - return Task.FromResult(Done.Instance); - }); + var port = i < listenersReady.BoundPorts.Count ? listenersReady.BoundPorts[i] : opts.Port; + addressesFeature.Addresses.Add(string.Concat(scheme, "://", host, ":", port.ToString())); + } - cs.AddTask(CoordinatedShutdown.PhaseServiceRequestsDone, "turbo-drain", async () => + if (_ownsSystem) { - await Task.Delay(_options.GracefulShutdownTimeout, CancellationToken.None); - return Done.Instance; - }); + var cs = CoordinatedShutdown.Get(_system); + + cs.AddTask(CoordinatedShutdown.PhaseBeforeServiceUnbind, "turbo-stop-accepting", () => + { + _supervisor.Tell(new ServerSupervisorActor.StopAccepting()); + return Task.FromResult(Done.Instance); + }); + + cs.AddTask(CoordinatedShutdown.PhaseServiceUnbind, "turbo-goaway", () => + { + _supervisor.Tell(new ServerSupervisorActor.BeginDrain(_options.GracefulShutdownTimeout)); + return Task.FromResult(Done.Instance); + }); + + cs.AddTask(CoordinatedShutdown.PhaseServiceRequestsDone, "turbo-drain", async () => + { + await Task.Delay(_options.GracefulShutdownTimeout, CancellationToken.None); + return Done.Instance; + }); + } } public async Task StopAsync(CancellationToken cancellationToken) { - if (_system is not null) + if (_system is null) + { + return; + } + + if (_ownsSystem) { await CoordinatedShutdown.Get(_system).Run(CoordinatedShutdown.ClrExitReason.Instance); } + else + { + _supervisor.Tell(new ServerSupervisorActor.StopAccepting()); + _supervisor.Tell(new ServerSupervisorActor.BeginDrain(_options.GracefulShutdownTimeout)); + await Task.Delay(_options.GracefulShutdownTimeout, cancellationToken); + await _pipelineOwner.GracefulStop(_options.GracefulShutdownTimeout); + await _supervisor.GracefulStop(_options.GracefulShutdownTimeout); + } } public void Dispose() diff --git a/src/TurboHTTP/Streams/Lifecycle/ConnectionActor.cs b/src/TurboHTTP/Streams/Lifecycle/ConnectionActor.cs index 2a3c5b2f2..72efb4fe9 100644 --- a/src/TurboHTTP/Streams/Lifecycle/ConnectionActor.cs +++ b/src/TurboHTTP/Streams/Lifecycle/ConnectionActor.cs @@ -10,6 +10,8 @@ using Microsoft.Extensions.Logging; using Servus.Akka.Transport; using TurboHTTP.Diagnostics; +using TurboHTTP.Server.Context.Features; +using TurboHTTP.Streams.Stages.Server; using static Servus.Core.Servus; namespace TurboHTTP.Streams.Lifecycle; @@ -35,7 +37,9 @@ internal sealed class ConnectionActor : ReceiveActor public sealed record Materialize( Flow ConnectionFlow, IServerProtocolEngine Engine, - Flow BridgeFlow, + int ConnectionId, + Sink RequestIngress, + IResponseDispatcher Dispatcher, IServiceProvider Services, IMaterializer Materializer, string? ConnectionLoggingCategory = null, @@ -69,7 +73,29 @@ private void OnMaterialize(Materialize msg) _killSwitch = KillSwitches.Shared("connection-" + _connectionId); var protocolBidi = msg.Engine.CreateFlow(msg.Services); - var composed = protocolBidi.Join(msg.BridgeFlow); + var connectionId = msg.ConnectionId; + + var responseSource = msg.Dispatcher.Subscribe(msg.ConnectionId); + + var bridge = Flow.FromGraph(GraphDsl.Create(b => + { + var tagAndSink = b.Add( + Flow.Create() + .Select(features => + { + features.Set(new ConnectionRoutingFeature { ConnectionId = connectionId }); + return features; + }) + .To(msg.RequestIngress)); + + var response = b.Add(responseSource); + + return new FlowShape( + tagAndSink.Inlet, + response.Outlet); + })); + + var composed = protocolBidi.Join(bridge); if (Metrics.ProtocolNegotiationDuration().Enabled) { diff --git a/src/TurboHTTP/Streams/Lifecycle/ListenerActor.cs b/src/TurboHTTP/Streams/Lifecycle/ListenerActor.cs index f9010928f..ff1123818 100644 --- a/src/TurboHTTP/Streams/Lifecycle/ListenerActor.cs +++ b/src/TurboHTTP/Streams/Lifecycle/ListenerActor.cs @@ -9,6 +9,7 @@ using Servus.Akka.Transport; using TurboHTTP.Diagnostics; using TurboHTTP.Server; +using TurboHTTP.Streams.Stages.Server; using static Servus.Core.Servus; namespace TurboHTTP.Streams.Lifecycle; @@ -19,13 +20,14 @@ internal sealed class ListenerActor : ReceiveActor private readonly IListenerFactory _factory; private readonly ListenerOptions _listenerOptions; private readonly TurboServerOptions _serverOptions; - private readonly Flow _bridgeFlow; + private readonly Sink _requestIngress; + private readonly IResponseDispatcher _dispatcher; private readonly IServiceProvider _services; private readonly IMaterializer _materializer; private readonly string? _connectionLoggingCategory; private UniqueKillSwitch? _listenerKillSwitch; - private int _connectionCounter; + private int _connectionIdCounter; private readonly HashSet _activeConnections = []; private readonly Dictionary _connectionMetrics = new(); private bool _draining; @@ -40,9 +42,9 @@ internal sealed record ConnectionStarted(string ConnectionId, IActorRef Connecti internal sealed record IncomingConnection(Flow ConnectionFlow); - internal sealed record ListeningStarted; + internal sealed record ListeningStarted(int BoundPort); - private sealed record BindCompleted(IActorRef ReplyTo); + private sealed record BindCompleted(IActorRef ReplyTo, int BoundPort); internal sealed record ListenerStopped; @@ -52,7 +54,8 @@ public ListenerActor( IListenerFactory factory, ListenerOptions listenerOptions, TurboServerOptions serverOptions, - Flow bridgeFlow, + Sink requestIngress, + IResponseDispatcher dispatcher, IServiceProvider services, IMaterializer materializer, string? connectionLoggingCategory = null) @@ -60,7 +63,8 @@ public ListenerActor( _factory = factory; _listenerOptions = listenerOptions; _serverOptions = serverOptions; - _bridgeFlow = bridgeFlow; + _requestIngress = requestIngress; + _dispatcher = dispatcher; _services = services; _materializer = materializer; _connectionLoggingCategory = connectionLoggingCategory; @@ -96,7 +100,7 @@ private void OnStartListening() _listenerKillSwitch = killSwitch; boundTask.PipeTo(Self, - success: () => new BindCompleted(sender), + success: port => new BindCompleted(sender, port), failure: ex => new ListenerFailed(ex)); completionTask.PipeTo(Self, @@ -106,7 +110,7 @@ private void OnStartListening() private void OnBindCompleted(BindCompleted msg) { - msg.ReplyTo.Tell(new ListeningStarted()); + msg.ReplyTo.Tell(new ListeningStarted(msg.BoundPort)); } private void OnIncomingConnection(IncomingConnection msg) @@ -124,7 +128,8 @@ private void OnIncomingConnection(IncomingConnection msg) return; } - var connectionId = string.Concat("conn-", ++_connectionCounter); + var connectionId = ++_connectionIdCounter; + var stringConnectionId = string.Concat("conn-", connectionId); var engine = ResolveEngineForListener(); long timestamp = 0; @@ -135,7 +140,7 @@ private void OnIncomingConnection(IncomingConnection msg) OnIncomingConnectionInstrumented(out timestamp, out connectionActivity); } - var child = Context.ActorOf(ConnectionActor.Create(connectionId), connectionId); + var child = Context.ActorOf(ConnectionActor.Create(stringConnectionId), stringConnectionId); Context.Watch(child); _activeConnections.Add(child); _connectionMetrics[child] = (timestamp, connectionActivity); @@ -143,14 +148,16 @@ private void OnIncomingConnection(IncomingConnection msg) child.Tell(new ConnectionActor.Materialize( msg.ConnectionFlow, engine, - _bridgeFlow, + connectionId, + _requestIngress, + _dispatcher, _services, _materializer, _connectionLoggingCategory, timestamp, connectionActivity)); - Context.Parent.Tell(new ConnectionStarted(connectionId, child)); + Context.Parent.Tell(new ConnectionStarted(stringConnectionId, child)); } [MethodImpl(MethodImplOptions.NoInlining)] @@ -289,12 +296,14 @@ public static Props Create( IListenerFactory factory, ListenerOptions listenerOptions, TurboServerOptions serverOptions, - Flow bridgeFlow, + Sink requestIngress, + IResponseDispatcher dispatcher, IServiceProvider services, IMaterializer materializer, string? connectionLoggingCategory = null) => Props.Create(() => new ListenerActor( factory, listenerOptions, serverOptions, - bridgeFlow, services, materializer, + requestIngress, dispatcher, + services, materializer, connectionLoggingCategory)); } \ No newline at end of file diff --git a/src/TurboHTTP/Streams/Lifecycle/ServerPipelineOwner.cs b/src/TurboHTTP/Streams/Lifecycle/ServerPipelineOwner.cs new file mode 100644 index 000000000..63504e69f --- /dev/null +++ b/src/TurboHTTP/Streams/Lifecycle/ServerPipelineOwner.cs @@ -0,0 +1,137 @@ +using Akka; +using Akka.Actor; +using Akka.Event; +using Akka.Streams; +using Akka.Streams.Dsl; +using Microsoft.AspNetCore.Http.Features; +using TurboHTTP.Streams.Stages.Server; + +namespace TurboHTTP.Streams.Lifecycle; + +internal sealed class ServerPipelineOwner : ReceiveActor, IWithStash +{ + internal sealed record Initialize; + internal sealed record PipelineReady( + Sink RequestIngress, + IResponseDispatcher Dispatcher); + + private readonly ILoggingAdapter _log = Context.GetLogger(); + private readonly Flow _bridgeFlow; + + private ActorMaterializer? _materializer; + private Sink? _requestIngress; + private IResponseDispatcher? _dispatcher; + private SharedKillSwitch? _killSwitch; + + public IStash Stash { get; set; } = null!; + + public ServerPipelineOwner(Flow bridgeFlow) + { + _bridgeFlow = bridgeFlow; + Initializing(); + } + + private void Initializing() + { + Receive(_ => MaterializePipeline()); + } + + private void Ready() + { + Receive(_ => + { + Sender.Tell(new PipelineReady(_requestIngress!, _dispatcher!)); + }); + } + + protected override void PreStart() + { + _log.Debug("ServerPipelineOwner starting"); + } + + private void MaterializePipeline() + { + _log.Debug("Materializing server pipeline"); + + try + { + var materializerSettings = ActorMaterializerSettings.Create(Context.System) + .WithInputBuffer(initialSize: 32, maxSize: 128); + _materializer = Context.System.Materializer( + settings: materializerSettings, + namePrefix: $"server-pipeline-{Self.Path.Name}"); + + _killSwitch = KillSwitches.Shared($"server-{Self.Path.Name}"); + + var requestIngressHub = MergeHub.Source(perProducerBufferSize: 64); + var dispatcherHub = new ResponseDispatcherHub(); + + var (requestIngress, dispatcher) = requestIngressHub + .Via(_killSwitch.Flow()) + .Via(_bridgeFlow) + .ToMaterialized(Sink.FromGraph(dispatcherHub), Keep.Both) + .Run(_materializer); + + _requestIngress = requestIngress; + _dispatcher = dispatcher; + + _log.Debug("Server pipeline materialized successfully"); + BecomeReady(); + Sender.Tell(new PipelineReady(_requestIngress!, _dispatcher!)); + } + catch (Exception ex) + { + _log.Error(ex, "Failed to materialize server pipeline"); + CleanupResources(); + Context.Stop(Self); + } + } + + private void BecomeReady() + { + Become(Ready); + Stash.UnstashAll(); + } + + private void CleanupResources() + { + _log.Debug("Cleaning up server pipeline resources"); + + if (_killSwitch is not null) + { + try + { + _killSwitch.Shutdown(); + } + catch (Exception ex) + { + _log.Warning("Error shutting down KillSwitch: {0}", ex.Message); + } + } + + if (_materializer is not null) + { + try + { + _materializer.Dispose(); + } + catch (Exception ex) + { + _log.Warning("Error disposing materializer: {0}", ex.Message); + } + + _materializer = null; + } + + _killSwitch = null; + _dispatcher = null; + _requestIngress = null; + } + + protected override void PostStop() + { + _log.Debug("PostStop: cleaning up server pipeline resources"); + CleanupResources(); + base.PostStop(); + } +} diff --git a/src/TurboHTTP/Streams/Lifecycle/ServerSupervisorActor.cs b/src/TurboHTTP/Streams/Lifecycle/ServerSupervisorActor.cs index 17d92dbf2..efd9e3751 100644 --- a/src/TurboHTTP/Streams/Lifecycle/ServerSupervisorActor.cs +++ b/src/TurboHTTP/Streams/Lifecycle/ServerSupervisorActor.cs @@ -8,11 +8,12 @@ internal sealed class ServerSupervisorActor : ReceiveActor private readonly ILoggingAdapter _log = Context.GetLogger(); private readonly Dictionary _activeConnections = new(); private readonly List _listeners = []; + private readonly List _boundPorts = []; private IActorRef _startRequester = ActorRefs.Nobody; private int _pendingListenerCount; public sealed record StartListeners(IReadOnlyList ListenerProps); - public sealed record ListenersReady; + public sealed record ListenersReady(IReadOnlyList BoundPorts); public sealed record StopAccepting; public sealed record BeginDrain(TimeSpan Timeout); public sealed record DrainComplete; @@ -21,7 +22,7 @@ public sealed record GetConnectionCount; public ServerSupervisorActor() { Receive(OnStartListeners); - Receive(_ => OnListenerReady()); + Receive(msg => OnListenerReady(msg.BoundPort)); Receive(_ => OnStopAccepting()); Receive(OnBeginDrain); Receive(OnConnectionStarted); @@ -36,7 +37,7 @@ private void OnStartListeners(StartListeners msg) if (_pendingListenerCount == 0) { - _startRequester.Tell(new ListenersReady()); + _startRequester.Tell(new ListenersReady([])); return; } @@ -49,13 +50,14 @@ private void OnStartListeners(StartListeners msg) } } - private void OnListenerReady() + private void OnListenerReady(int boundPort) { + _boundPorts.Add(boundPort); _pendingListenerCount--; if (_pendingListenerCount <= 0) { _log.Info("All {0} listener(s) ready", _listeners.Count); - _startRequester.Tell(new ListenersReady()); + _startRequester.Tell(new ListenersReady(_boundPorts)); _startRequester = ActorRefs.Nobody; } } diff --git a/src/TurboHTTP/Streams/Stages/Server/HttpConnectionServerStageLogic.cs b/src/TurboHTTP/Streams/Stages/Server/HttpConnectionServerStageLogic.cs index d024b3536..8d35c4d64 100644 --- a/src/TurboHTTP/Streams/Stages/Server/HttpConnectionServerStageLogic.cs +++ b/src/TurboHTTP/Streams/Stages/Server/HttpConnectionServerStageLogic.cs @@ -60,7 +60,20 @@ public HttpConnectionServerStageLogic( { Tracing.For("Stage").Info(this, "network upstream failure: {0}", ex.Message); _sm.OnDownstreamFinished(); - CompleteStage(); + if (!IsClosed(_outRequest)) + { + Complete(_outRequest); + } + + if (!IsClosed(_inResponse)) + { + Cancel(_inResponse); + } + + if (!IsClosed(_outNetwork)) + { + Complete(_outNetwork); + } }); SetHandler(_outRequest, onPull: () => @@ -122,10 +135,42 @@ public HttpConnectionServerStageLogic( onUpstreamFailure: _ => { _sm.OnDownstreamFinished(); - CompleteStage(); + if (!IsClosed(_outRequest)) + { + Complete(_outRequest); + } + + if (!IsClosed(_inNetwork)) + { + Cancel(_inNetwork); + } + + if (!IsClosed(_outNetwork)) + { + Complete(_outNetwork); + } }); - SetHandler(_outNetwork, onPull: OnNetworkPull); + SetHandler(_outNetwork, + onPull: OnNetworkPull, + onDownstreamFinish: _ => + { + _sm.OnDownstreamFinished(); + if (!IsClosed(_outRequest)) + { + Complete(_outRequest); + } + + if (!IsClosed(_inResponse)) + { + Cancel(_inResponse); + } + + if (!IsClosed(_inNetwork)) + { + Cancel(_inNetwork); + } + }); } public override void PreStart() diff --git a/src/TurboHTTP/Streams/Stages/Server/ResponseDispatcherHub.cs b/src/TurboHTTP/Streams/Stages/Server/ResponseDispatcherHub.cs new file mode 100644 index 000000000..9532d947d --- /dev/null +++ b/src/TurboHTTP/Streams/Stages/Server/ResponseDispatcherHub.cs @@ -0,0 +1,243 @@ +using Akka; +using Akka.Actor; +using Akka.Streams; +using Akka.Streams.Dsl; +using Akka.Streams.Stage; +using Microsoft.AspNetCore.Http.Features; +using TurboHTTP.Server.Context.Features; + +namespace TurboHTTP.Streams.Stages.Server; + +internal interface IResponseDispatcher +{ + Source Subscribe(int connectionId); +} + +internal sealed class ResponseDispatcherHub + : GraphStageWithMaterializedValue, IResponseDispatcher> +{ + private readonly Inlet _in = new("ResponseDispatcher.In"); + + public override SinkShape Shape { get; } + + public ResponseDispatcherHub() + { + Shape = new SinkShape(_in); + } + + public override ILogicAndMaterializedValue> + CreateLogicAndMaterializedValue(Attributes inheritedAttributes) + { + var sinkActorTcs = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var logic = new DispatcherLogic(this, sinkActorTcs); + var dispatcher = new ResponseDispatcherImpl(sinkActorTcs.Task); + return new LogicAndMaterializedValue>(logic, dispatcher); + } + + private sealed record Register(int ConnectionId, IActorRef SourceActor); + + private sealed record Unregister(int ConnectionId); + + private sealed record Deliver(IFeatureCollection Element); + + private sealed record HubCompleted(Exception? Failure); + + private sealed class DispatcherLogic : GraphStageLogic + { + private readonly ResponseDispatcherHub _hub; + private readonly TaskCompletionSource _sinkActorTcs; + private readonly Dictionary _consumers = []; + private readonly Dictionary> _pending = []; + private IActorRef? _sinkActor; + + public DispatcherLogic( + ResponseDispatcherHub hub, + TaskCompletionSource sinkActorTcs) : base(hub.Shape) + { + _hub = hub; + _sinkActorTcs = sinkActorTcs; + + SetHandler(hub._in, + onPush: OnPush, + onUpstreamFinish: () => + { + foreach (var consumer in _consumers.Values) + { + consumer.Tell(new HubCompleted(null)); + } + + CompleteStage(); + }, + onUpstreamFailure: ex => + { + foreach (var consumer in _consumers.Values) + { + consumer.Tell(new HubCompleted(ex)); + } + + FailStage(ex); + }); + } + + public override void PreStart() + { + _sinkActor = GetStageActor(OnHubMessage).Ref; + _sinkActorTcs.SetResult(_sinkActor); + Pull(_hub._in); + } + + private void OnPush() + { + var element = Grab(_hub._in); + var routingFeature = element.Get(); + + if (routingFeature is not null) + { + var id = routingFeature.ConnectionId; + if (_consumers.TryGetValue(id, out var sourceActor)) + { + sourceActor.Tell(new Deliver(element)); + } + else + { + if (!_pending.TryGetValue(id, out var list)) + { + list = []; + _pending[id] = list; + } + + list.Add(element); + } + } + + Pull(_hub._in); + } + + private void OnHubMessage((IActorRef sender, object msg) args) + { + switch (args.msg) + { + case Register(var id, var sourceActor): + _consumers[id] = sourceActor; + if (_pending.Remove(id, out var buffered)) + { + foreach (var element in buffered) + { + sourceActor.Tell(new Deliver(element)); + } + } + + break; + case Unregister(var id): + _consumers.Remove(id); + _pending.Remove(id); + break; + } + } + } + + private sealed class ResponseDispatcherImpl(Task sinkActorTask) : IResponseDispatcher + { + public Source Subscribe(int connectionId) + { + return Source.FromGraph(new DispatcherSourceStage(sinkActorTask, connectionId)); + } + } + + private sealed class DispatcherSourceStage : GraphStage> + { + private readonly Task _sinkActorTask; + private readonly int _connectionId; + private readonly Outlet _out = new("ResponseDispatcher.Source.Out"); + + public override SourceShape Shape { get; } + + public DispatcherSourceStage(Task sinkActorTask, int connectionId) + { + _sinkActorTask = sinkActorTask; + _connectionId = connectionId; + Shape = new SourceShape(_out); + } + + protected override GraphStageLogic CreateLogic(Attributes inheritedAttributes) + => new SourceLogic(this); + + private sealed record SinkActorReady(IActorRef SinkActor); + + private sealed class SourceLogic : GraphStageLogic + { + private readonly DispatcherSourceStage _stage; + private IActorRef? _sourceActor; + private IActorRef? _sinkActor; + private IFeatureCollection? _buffered; + private bool _downstreamReady; + + public SourceLogic(DispatcherSourceStage stage) : base(stage.Shape) + { + _stage = stage; + + SetHandler(stage._out, onPull: () => + { + if (_buffered is { } element) + { + _buffered = null; + Push(_stage._out, element); + } + else + { + _downstreamReady = true; + } + }); + } + + public override void PreStart() + { + _sourceActor = GetStageActor(OnSourceMessage).Ref; + _stage._sinkActorTask.PipeTo(_sourceActor, + success: sinkRef => new SinkActorReady(sinkRef)); + } + + private void OnSourceMessage((IActorRef sender, object msg) args) + { + switch (args.msg) + { + case SinkActorReady(var sinkActor): + _sinkActor = sinkActor; + sinkActor.Tell(new Register(_stage._connectionId, _sourceActor!)); + break; + + case Deliver(var element): + if (_downstreamReady) + { + _downstreamReady = false; + Push(_stage._out, element); + } + else + { + _buffered = element; + } + + break; + + case HubCompleted(var failure): + if (failure is not null) + { + FailStage(failure); + } + else + { + CompleteStage(); + } + + break; + } + } + + public override void PostStop() + { + _sinkActor?.Tell(new Unregister(_stage._connectionId)); + } + } + } +} \ No newline at end of file diff --git a/src/TurboHTTP/Streams/Stages/Server/SharedBridgeStage.cs b/src/TurboHTTP/Streams/Stages/Server/SharedBridgeStage.cs new file mode 100644 index 000000000..a7987c484 --- /dev/null +++ b/src/TurboHTTP/Streams/Stages/Server/SharedBridgeStage.cs @@ -0,0 +1,428 @@ +using Akka.Actor; +using Akka.Streams; +using Akka.Streams.Stage; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Http.Features; +using System.Diagnostics; +using System.Runtime.CompilerServices; +using TurboHTTP.Diagnostics; +using TurboHTTP.Server.Context.Features; +using static Servus.Core.Servus; + +namespace TurboHTTP.Streams.Stages.Server; + +internal sealed class SharedBridgeStage : GraphStage> + where TContext : notnull +{ + private readonly IHttpApplication _application; + private readonly int _parallelism; + private readonly TimeSpan _handlerTimeout; + private readonly TimeSpan _handlerGracePeriod; + + private readonly Inlet _in = new("SharedBridge.In"); + private readonly Outlet _out = new("SharedBridge.Out"); + + public override FlowShape Shape { get; } + + public SharedBridgeStage( + IHttpApplication application, + int parallelism, + TimeSpan handlerTimeout, + TimeSpan handlerGracePeriod) + { + _application = application; + _parallelism = parallelism; + _handlerTimeout = handlerTimeout; + _handlerGracePeriod = handlerGracePeriod; + Shape = new FlowShape(_in, _out); + } + + protected override GraphStageLogic CreateLogic(Attributes inheritedAttributes) => new Logic(this); + + private sealed record DispatchCompleted(int Sequence, IFeatureCollection Features); + + private sealed record DispatchFailed(int Sequence, IFeatureCollection Features, Exception Error); + + private sealed record ResponseReady(int Sequence, IFeatureCollection Features, Task HandlerTask); + + private sealed record HandlerFinished(int Sequence, IFeatureCollection Features); + + private sealed record HandlerFaulted(int Sequence, IFeatureCollection Features, Exception Error); + + private sealed record HandlerTimedOut(int Sequence, IFeatureCollection Features); + + private sealed class Logic : GraphStageLogic + { + private readonly SharedBridgeStage _stage; + private IActorRef? _stageActor; + private bool _upstreamFinished; + private int _inFlight; + private int _sequence; + private bool _downstreamReady; + private readonly Queue _pending = []; + private readonly Dictionary _activeTimeouts = []; + private readonly Dictionary _appContexts = []; + private readonly bool _metricsEnabled; + private readonly int _backpressureThreshold; + private bool _backpressureSignaled; + + public Logic(SharedBridgeStage stage) : base(stage.Shape) + { + _stage = stage; + _metricsEnabled = Metrics.PipelineInFlight().Enabled + || Metrics.PipelinePending().Enabled + || Metrics.HandlerTimeouts().Enabled + || Tracing.IsServerTracingActive(); + _backpressureThreshold = (int)(stage._parallelism * 0.8); + + SetHandler(stage._in, + onPush: OnPush, + onUpstreamFinish: () => + { + _upstreamFinished = true; + if (_inFlight == 0) + { + CompleteStage(); + } + }); + + SetHandler(stage._out, + onPull: () => + { + _downstreamReady = true; + TryEmitPending(); + TryPullNext(); + }); + } + + public override void PreStart() + { + _stageActor = GetStageActor(OnMessage).Ref; + Pull(_stage._in); + } + + private void OnPush() + { + var features = Grab(_stage._in); + var seq = _sequence++; + + _inFlight++; + if (_metricsEnabled) + { + Metrics.PipelineInFlight().Add(1); + CheckBackpressure(); + } + + try + { + DispatchAsync(features, seq); + } + catch (Exception) + { + _inFlight--; + if (_metricsEnabled) + { + Metrics.PipelineInFlight().Add(-1); + } + var responseFeature = features.Get(); + if (responseFeature is not null) + { + responseFeature.StatusCode = 500; + } + CompleteResponseBody(features); + Emit(features); + } + + TryPullNext(); + } + + private void DispatchAsync(IFeatureCollection features, int seq) + { + TContext appContext; + try + { + appContext = _stage._application.CreateContext(features); + _appContexts[seq] = appContext; + } + catch (Exception) + { + _inFlight--; + var responseFeature = features.Get(); + if (responseFeature is not null) + { + responseFeature.StatusCode = 500; + } + CompleteResponseBody(features); + Emit(features); + return; + } + + var task = _stage._application.ProcessRequestAsync(appContext); + + if (task.IsCompletedSuccessfully) + { + _inFlight--; + _stage._application.DisposeContext(appContext, null); + _appContexts.Remove(seq); + CompleteResponseBody(features); + Emit(features); + } + else if (task.IsFaulted) + { + _inFlight--; + var responseFeature = features.Get(); + if (responseFeature is not null) + { + responseFeature.StatusCode = 500; + } + _stage._application.DisposeContext(appContext, task.Exception); + _appContexts.Remove(seq); + CompleteResponseBody(features); + Emit(features); + } + else + { + var lifetime = features.Get(); + var cts = lifetime is not null + ? CancellationTokenSource.CreateLinkedTokenSource(lifetime.RequestAborted) + : new CancellationTokenSource(); + cts.CancelAfter(_stage._handlerTimeout); + _activeTimeouts[seq] = cts; + + var bodyFeature = features.Get() as TurboHttpResponseBodyFeature; + var headersReady = bodyFeature?.WhenHeadersReady; + + Task.Delay(_stage._handlerTimeout + _stage._handlerGracePeriod, cts.Token) + .PipeTo(_stageActor!, + success: () => new HandlerTimedOut(seq, features)); + + if (headersReady is not null) + { + Task.WhenAny(headersReady, task) + .PipeTo(_stageActor!, + success: () => new ResponseReady(seq, features, task)); + } + else + { + task.PipeTo(_stageActor!, + success: () => new DispatchCompleted(seq, features), + failure: ex => new DispatchFailed(seq, features, ex)); + } + } + } + + private void OnMessage((IActorRef sender, object msg) args) + { + switch (args.msg) + { + case ResponseReady(var seq, var features, var handlerTask): + if (handlerTask.IsFaulted) + { + if (features.Get() is not TurboHttpResponseBodyFeature + { + HasStarted: true + }) + { + var responseFeature = features.Get(); + if (responseFeature is not null) + { + responseFeature.StatusCode = 500; + } + } + } + + if (handlerTask.IsCompleted) + { + CompleteResponseBody(features); + _inFlight--; + if (_metricsEnabled) + { + Metrics.PipelineInFlight().Add(-1); + ResetBackpressure(); + } + DisposeCts(seq); + DisposeAppContext(seq, handlerTask.Exception); + Emit(features); + } + else + { + Emit(features); + handlerTask.PipeTo(_stageActor!, + success: () => new HandlerFinished(seq, features), + failure: ex => new HandlerFaulted(seq, features, ex)); + } + + break; + + case HandlerFinished(var seq, var finishedFeatures): + CompleteResponseBody(finishedFeatures); + _inFlight--; + if (_metricsEnabled) + { + Metrics.PipelineInFlight().Add(-1); + ResetBackpressure(); + } + DisposeCts(seq); + DisposeAppContext(seq, null); + if (_upstreamFinished && _inFlight == 0) + { + CompleteStage(); + } + + break; + + case HandlerFaulted(var seq, var faultedFeatures, var error): + CompleteResponseBody(faultedFeatures); + _inFlight--; + if (_metricsEnabled) + { + Metrics.PipelineInFlight().Add(-1); + ResetBackpressure(); + } + DisposeCts(seq); + DisposeAppContext(seq, error); + if (_upstreamFinished && _inFlight == 0) + { + CompleteStage(); + } + + break; + + case DispatchCompleted(var seq, var features): + _inFlight--; + if (_metricsEnabled) + { + Metrics.PipelineInFlight().Add(-1); + ResetBackpressure(); + } + DisposeCts(seq); + DisposeAppContext(seq, null); + CompleteResponseBody(features); + Emit(features); + break; + + case DispatchFailed(var seq, var features, var error): + _inFlight--; + if (_metricsEnabled) + { + Metrics.PipelineInFlight().Add(-1); + ResetBackpressure(); + } + DisposeCts(seq); + DisposeAppContext(seq, error); + var respFeature = features.Get(); + if (respFeature is not null) + { + respFeature.StatusCode = 500; + } + CompleteResponseBody(features); + Emit(features); + break; + + case HandlerTimedOut(var seq, var features): + if (_activeTimeouts.TryGetValue(seq, out var cts)) + { + cts.Dispose(); + _activeTimeouts.Remove(seq); + var respFeatureTimeout = features.Get(); + if (respFeatureTimeout is not null && respFeatureTimeout.StatusCode == 200) + { + respFeatureTimeout.StatusCode = 503; + CompleteResponseBody(features); + _inFlight--; + if (_metricsEnabled) + { + Metrics.HandlerTimeouts().Add(1); + Metrics.PipelineInFlight().Add(-1); + } + DisposeAppContext(seq, null); + Emit(features); + } + } + + break; + } + + if (_upstreamFinished && _inFlight == 0 && _pending.Count == 0) + { + CompleteStage(); + } + } + + private void DisposeAppContext(int seq, Exception? exception) + { + if (_appContexts.TryGetValue(seq, out var appCtx)) + { + _stage._application.DisposeContext(appCtx, exception); + _appContexts.Remove(seq); + } + } + + private void DisposeCts(int seq) + { + if (_activeTimeouts.TryGetValue(seq, out var cts)) + { + cts.Dispose(); + _activeTimeouts.Remove(seq); + } + } + + private void TryPullNext() + { + if (_inFlight < _stage._parallelism && !HasBeenPulled(_stage._in)) + { + Pull(_stage._in); + } + } + + private void Emit(IFeatureCollection features) + { + _pending.Enqueue(features); + if (_metricsEnabled) + { + Metrics.PipelinePending().Add(1); + } + TryEmitPending(); + } + + private void TryEmitPending() + { + while (_downstreamReady && _pending.Count > 0) + { + _downstreamReady = false; + Push(_stage._out, _pending.Dequeue()); + if (_metricsEnabled) + { + Metrics.PipelinePending().Add(-1); + } + } + } + + private static void CompleteResponseBody(IFeatureCollection features) + { + var bodyFeature = features.Get() as TurboHttpResponseBodyFeature; + bodyFeature?.Complete(); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private void CheckBackpressure() + { + if (_inFlight >= _backpressureThreshold && !_backpressureSignaled) + { + _backpressureSignaled = true; + if (Activity.Current is { } connectionActivity) + { + Tracing.AddBackpressureEvent(connectionActivity, _inFlight, _stage._parallelism); + } + } + } + + private void ResetBackpressure() + { + if (_backpressureSignaled && _inFlight < _backpressureThreshold) + { + _backpressureSignaled = false; + } + } + } +}