diff --git a/lib/servus.akka b/lib/servus.akka index 547e3c1b3..e34b1c812 160000 --- a/lib/servus.akka +++ b/lib/servus.akka @@ -1 +1 @@ -Subproject commit 547e3c1b3069d6e309d6979f0058b17a2f3d0178 +Subproject commit e34b1c812f4ccec346ab24df45e189c6d065dbe0 diff --git a/src/GaudiHTTP.API.Tests/CoreAPISpec.cs b/src/GaudiHTTP.API.Tests/CoreAPISpec.cs index 260d4504a..3b3d5d11d 100644 --- a/src/GaudiHTTP.API.Tests/CoreAPISpec.cs +++ b/src/GaudiHTTP.API.Tests/CoreAPISpec.cs @@ -5,7 +5,7 @@ namespace GaudiHTTP.API.Tests; public class CoreAPISpec { - private static readonly ApiGeneratorOptions ApiOptions = new() + private static ApiGeneratorOptions MakeApiOptions() => new() { ExcludeAttributes = [ @@ -17,10 +17,10 @@ public class CoreAPISpec private static Task VerifyAssembly() { - return Verify(typeof(T).Assembly.GeneratePublicApi(ApiOptions)); + return Verify(typeof(T).Assembly.GeneratePublicApi(MakeApiOptions())); } - [Fact] + [Fact(Timeout = 5000)] public Task ApproveCore() { return VerifyAssembly(); diff --git a/src/GaudiHTTP.AcceptanceTests/H10/RedirectSpec.cs b/src/GaudiHTTP.AcceptanceTests/H10/RedirectSpec.cs index 569c45c71..e802b1aae 100644 --- a/src/GaudiHTTP.AcceptanceTests/H10/RedirectSpec.cs +++ b/src/GaudiHTTP.AcceptanceTests/H10/RedirectSpec.cs @@ -27,9 +27,9 @@ private async Task SendAsync(ResponseMap map, HttpRequestMe private static ResponseMap CreateBaseMap() => new ResponseMap() .On("/hello", HttpStatusCode.OK, "Hello World") - .On("/echo", req => + .On("/echo", async req => { - var body = req.Content?.ReadAsStringAsync().GetAwaiter().GetResult() ?? ""; + var body = req.Content != null ? await req.Content.ReadAsStringAsync() : ""; return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(body) diff --git a/src/GaudiHTTP.AcceptanceTests/H2/RedirectSpec.cs b/src/GaudiHTTP.AcceptanceTests/H2/RedirectSpec.cs index cb934f76e..2a0b37ca2 100644 --- a/src/GaudiHTTP.AcceptanceTests/H2/RedirectSpec.cs +++ b/src/GaudiHTTP.AcceptanceTests/H2/RedirectSpec.cs @@ -27,9 +27,9 @@ private async Task SendAsync(ResponseMap map, HttpRequestMe private static ResponseMap CreateBaseMap() => new ResponseMap() .On("/hello", HttpStatusCode.OK, "Hello World") - .On("/echo", req => + .On("/echo", async req => { - var body = req.Content?.ReadAsStringAsync().GetAwaiter().GetResult() ?? ""; + var body = req.Content != null ? await req.Content.ReadAsStringAsync() : ""; return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(body) diff --git a/src/GaudiHTTP.AcceptanceTests/H3/RedirectSpec.cs b/src/GaudiHTTP.AcceptanceTests/H3/RedirectSpec.cs index 6ff4b9aed..f47ed16e8 100644 --- a/src/GaudiHTTP.AcceptanceTests/H3/RedirectSpec.cs +++ b/src/GaudiHTTP.AcceptanceTests/H3/RedirectSpec.cs @@ -27,9 +27,9 @@ private async Task SendAsync(ResponseMap map, HttpRequestMe private static ResponseMap CreateBaseMap() => new ResponseMap() .On("/hello", HttpStatusCode.OK, "Hello World") - .On("/echo", req => + .On("/echo", async req => { - var body = req.Content?.ReadAsStringAsync().GetAwaiter().GetResult() ?? ""; + var body = req.Content != null ? await req.Content.ReadAsStringAsync() : ""; return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(body) diff --git a/src/GaudiHTTP.AcceptanceTests/TLS/IntegrationSpec.cs b/src/GaudiHTTP.AcceptanceTests/TLS/IntegrationSpec.cs index 8b1cbfdae..70df4938e 100644 --- a/src/GaudiHTTP.AcceptanceTests/TLS/IntegrationSpec.cs +++ b/src/GaudiHTTP.AcceptanceTests/TLS/IntegrationSpec.cs @@ -77,9 +77,9 @@ public async Task Post_echo_should_echo_body_over_https() { var payload = "TLS echo payload"; var map = new ResponseMap() - .On("/echo", req => + .On("/echo", async req => { - var reqBody = req.Content?.ReadAsStringAsync().GetAwaiter().GetResult() ?? ""; + var reqBody = req.Content != null ? await req.Content.ReadAsStringAsync() : ""; return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(reqBody) diff --git a/src/GaudiHTTP.AcceptanceTests/TLS/RedirectSpec.cs b/src/GaudiHTTP.AcceptanceTests/TLS/RedirectSpec.cs index 39c51e4df..42254c851 100644 --- a/src/GaudiHTTP.AcceptanceTests/TLS/RedirectSpec.cs +++ b/src/GaudiHTTP.AcceptanceTests/TLS/RedirectSpec.cs @@ -27,9 +27,9 @@ private async Task SendAsync(ResponseMap map, HttpRequestMe private static ResponseMap CreateBaseMap() => new ResponseMap() .On("/hello", HttpStatusCode.OK, "Hello World") - .On("/echo", req => + .On("/echo", async req => { - var body = req.Content?.ReadAsStringAsync().GetAwaiter().GetResult() ?? ""; + var body = req.Content != null ? await req.Content.ReadAsStringAsync() : ""; return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(body) diff --git a/src/GaudiHTTP.Benchmarks/Internal/BenchmarkServer.cs b/src/GaudiHTTP.Benchmarks/Internal/BenchmarkServer.cs index 93ac9f920..81625f0b7 100644 --- a/src/GaudiHTTP.Benchmarks/Internal/BenchmarkServer.cs +++ b/src/GaudiHTTP.Benchmarks/Internal/BenchmarkServer.cs @@ -23,10 +23,14 @@ public sealed class BenchmarkServer : IAsyncDisposable public int Http30Port { get; private set; } + public bool IsQuicAvailable { get; private set; } + public async ValueTask InitializeAsync(IAllocationProfiler? profiler = null) { _cert = GenerateSelfSignedCert(); + var quicAvailable = QuicListenerIsSupported(); + var builder = WebApplication.CreateBuilder(); builder.Logging.ClearProviders(); @@ -41,12 +45,15 @@ public async ValueTask InitializeAsync(IAllocationProfiler? profiler = null) options.Listen(IPAddress.Loopback, 0, lo => lo.Protocols = HttpProtocols.Http2); - // HTTP/3 (QUIC+TLS) listener - options.Listen(IPAddress.Loopback, 0, lo => + if (quicAvailable) { - lo.Protocols = HttpProtocols.Http3; - lo.UseHttps(cert); - }); + // HTTP/3 (QUIC+TLS) listener + options.Listen(IPAddress.Loopback, 0, lo => + { + lo.Protocols = HttpProtocols.Http3; + lo.UseHttps(cert); + }); + } // Raise HTTP/2 limits to support high-concurrency benchmarks (CL=256+). options.Limits.Http2.MaxStreamsPerConnection = 512; @@ -59,11 +66,14 @@ public async ValueTask InitializeAsync(IAllocationProfiler? profiler = null) options.Limits.MaxConcurrentUpgradedConnections = null; }); - builder.WebHost.UseQuic(quic => + if (quicAvailable) { - quic.MaxBidirectionalStreamCount = 512; - quic.MaxUnidirectionalStreamCount = 32; - }); + builder.WebHost.UseQuic(quic => + { + quic.MaxBidirectionalStreamCount = 512; + quic.MaxUnidirectionalStreamCount = 32; + }); + } var app = builder.Build(); @@ -71,8 +81,6 @@ public async ValueTask InitializeAsync(IAllocationProfiler? profiler = null) await app.StartAsync(); - // Kestrel returns addresses in listener-registration order: - // index 0 = HTTP/1.1, index 1 = HTTP/2, index 2 = HTTP/3 var addresses = app.Services.GetRequiredService() .Features.Get()! .Addresses @@ -80,11 +88,31 @@ public async ValueTask InitializeAsync(IAllocationProfiler? profiler = null) Http11Port = new Uri(addresses[0]).Port; Http20Port = new Uri(addresses[1]).Port; - Http30Port = new Uri(addresses[2]).Port; + Http30Port = quicAvailable ? new Uri(addresses[2]).Port : 0; + IsQuicAvailable = quicAvailable; _app = app; } + private static bool QuicListenerIsSupported() + { + try + { + var type = Type.GetType("System.Net.Quic.QuicListener, System.Net.Quic"); + if (type is null) + { + return false; + } + + var prop = type.GetProperty("IsSupported", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static); + return prop?.GetValue(null) is true; + } + catch + { + return false; + } + } + public async ValueTask DisposeAsync() { if (_app is not null) diff --git a/src/GaudiHTTP.Benchmarks/Internal/Config.cs b/src/GaudiHTTP.Benchmarks/Internal/Config.cs index d78b6aa06..54a26e325 100644 --- a/src/GaudiHTTP.Benchmarks/Internal/Config.cs +++ b/src/GaudiHTTP.Benchmarks/Internal/Config.cs @@ -131,7 +131,7 @@ public EngineBenchmarkConfig() WithArtifactsPath(artifactsPath); AddJob(Job.Default.WithGcServer(true)); AddDiagnoser(MemoryDiagnoser.Default); - AddDiagnoser(new EventPipeProfiler(EventPipeProfile.GcVerbose)); + AddDiagnoser(ThreadingDiagnoser.Default); AddExporter(MarkdownExporter.GitHub); AddExporter(HttpVersionColorExporter.Default); AddExporter(AllocationByTypeExporter.Default); diff --git a/src/GaudiHTTP.IntegrationTests.Client/TestInitializer.cs b/src/GaudiHTTP.IntegrationTests.Client/TestInitializer.cs deleted file mode 100644 index 4c526c2d4..000000000 --- a/src/GaudiHTTP.IntegrationTests.Client/TestInitializer.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.Runtime.CompilerServices; - -namespace GaudiHTTP.IntegrationTests.Client; - -internal static class TestInitializer -{ - [ModuleInitializer] - internal static void Initialize() - { - ThreadPool.SetMinThreads(256, 256); - } -} diff --git a/src/GaudiHTTP.IntegrationTests.End2End/H11/HandlerMiddlewareSpec.cs b/src/GaudiHTTP.IntegrationTests.End2End/H11/HandlerMiddlewareSpec.cs index c88dce27d..82f3478f4 100644 --- a/src/GaudiHTTP.IntegrationTests.End2End/H11/HandlerMiddlewareSpec.cs +++ b/src/GaudiHTTP.IntegrationTests.End2End/H11/HandlerMiddlewareSpec.cs @@ -63,8 +63,7 @@ public override HttpRequestMessage ProcessRequest(HttpRequestMessage request) public async Task Handler_should_inject_request_headers_that_reach_server() { // Create a separate client with header-injecting handler - var system = await GetActorSystemAsync(); - var client = CreateClientWithHandler(); + var client = await CreateClientWithHandlerAsync(); var request = new HttpRequestMessage(HttpMethod.Get, $"{BaseUri}/echo-headers"); var response = await client.SendAsync(request, CancellationToken); @@ -77,7 +76,7 @@ public async Task Handler_should_inject_request_headers_that_reach_server() [Fact(Timeout = 10000)] public async Task Handler_should_fail_per_request_when_throwing() { - var client = CreateClientWithHandler(); + var client = await CreateClientWithHandlerAsync(); var request = new HttpRequestMessage(HttpMethod.Get, $"{BaseUri}/ping"); var ex = await Assert.ThrowsAsync(() => client.SendAsync(request, CancellationToken)); @@ -87,7 +86,7 @@ public async Task Handler_should_fail_per_request_when_throwing() [Fact(Timeout = 10000)] public async Task Handler_should_fail_only_faulted_request_while_others_succeed() { - var client = CreateClientWithHandler(); + var client = await CreateClientWithHandlerAsync(); // Send a failing request var failingRequest = new HttpRequestMessage(HttpMethod.Get, $"{BaseUri}/ping"); @@ -107,10 +106,10 @@ public async Task Handler_should_fail_only_faulted_request_while_others_succeed( Assert.Equal(HttpStatusCode.OK, goodResponse.StatusCode); } - private IGaudiHttpClient CreateClientWithHandler() where THandler : GaudiHandler + private async Task CreateClientWithHandlerAsync() where THandler : GaudiHandler { var services = new ServiceCollection(); - services.AddSingleton(GetActorSystemAsync().Result); + services.AddSingleton(await GetActorSystemAsync()); var clientOptions = new GaudiClientOptions { diff --git a/src/GaudiHTTP.IntegrationTests.End2End/H2/HandlerMiddlewareSpec.cs b/src/GaudiHTTP.IntegrationTests.End2End/H2/HandlerMiddlewareSpec.cs index 760bf891f..34ca6d70b 100644 --- a/src/GaudiHTTP.IntegrationTests.End2End/H2/HandlerMiddlewareSpec.cs +++ b/src/GaudiHTTP.IntegrationTests.End2End/H2/HandlerMiddlewareSpec.cs @@ -63,8 +63,7 @@ public override HttpRequestMessage ProcessRequest(HttpRequestMessage request) public async Task Handler_should_inject_request_headers_that_reach_server() { // Create a separate client with header-injecting handler - var system = await GetActorSystemAsync(); - var client = CreateClientWithHandler(); + var client = await CreateClientWithHandlerAsync(); var request = new HttpRequestMessage(HttpMethod.Get, $"{BaseUri}/echo-headers"); var response = await client.SendAsync(request, CancellationToken); @@ -77,7 +76,7 @@ public async Task Handler_should_inject_request_headers_that_reach_server() [Fact(Timeout = 10000)] public async Task Handler_should_fail_per_request_when_throwing() { - var client = CreateClientWithHandler(); + var client = await CreateClientWithHandlerAsync(); var request = new HttpRequestMessage(HttpMethod.Get, $"{BaseUri}/ping"); var ex = await Assert.ThrowsAsync(() => client.SendAsync(request, CancellationToken)); @@ -87,7 +86,7 @@ public async Task Handler_should_fail_per_request_when_throwing() [Fact(Timeout = 10000)] public async Task Handler_should_fail_only_faulted_request_while_others_succeed() { - var client = CreateClientWithHandler(); + var client = await CreateClientWithHandlerAsync(); // Send a failing request var failingRequest = new HttpRequestMessage(HttpMethod.Get, $"{BaseUri}/ping"); @@ -107,10 +106,10 @@ public async Task Handler_should_fail_only_faulted_request_while_others_succeed( Assert.Equal(HttpStatusCode.OK, goodResponse.StatusCode); } - private IGaudiHttpClient CreateClientWithHandler() where THandler : GaudiHandler + private async Task CreateClientWithHandlerAsync() where THandler : GaudiHandler { var services = new ServiceCollection(); - services.AddSingleton(GetActorSystemAsync().Result); + services.AddSingleton(await GetActorSystemAsync()); var clientOptions = new GaudiClientOptions { diff --git a/src/GaudiHTTP.IntegrationTests.End2End/H2/SendAsyncHighConcurrencySpec.cs b/src/GaudiHTTP.IntegrationTests.End2End/H2/SendAsyncHighConcurrencySpec.cs index 3cf60aff5..9c2c41501 100644 --- a/src/GaudiHTTP.IntegrationTests.End2End/H2/SendAsyncHighConcurrencySpec.cs +++ b/src/GaudiHTTP.IntegrationTests.End2End/H2/SendAsyncHighConcurrencySpec.cs @@ -135,6 +135,8 @@ public async ValueTask DisposeAsync() await _clientProvider.DisposeAsync(); } + + Tracing.Disable(); } /// diff --git a/src/GaudiHTTP.Tests.Shared/ActorSystemFixture.cs b/src/GaudiHTTP.Tests.Shared/ActorSystemFixture.cs index f1bb2a5ef..fe5066679 100644 --- a/src/GaudiHTTP.Tests.Shared/ActorSystemFixture.cs +++ b/src/GaudiHTTP.Tests.Shared/ActorSystemFixture.cs @@ -39,5 +39,6 @@ public async ValueTask DisposeAsync() { await System.Terminate().WaitAsync(TimeSpan.FromSeconds(30)); await System.WhenTerminated.WaitAsync(TimeSpan.FromSeconds(30)); + Servus.Senf.Tracing.Disable(); } } \ No newline at end of file diff --git a/src/GaudiHTTP.Tests.Shared/FakeResponse.cs b/src/GaudiHTTP.Tests.Shared/FakeResponse.cs index 921b1c06d..053987374 100644 --- a/src/GaudiHTTP.Tests.Shared/FakeResponse.cs +++ b/src/GaudiHTTP.Tests.Shared/FakeResponse.cs @@ -1,10 +1,11 @@ +using System.Collections.Frozen; using System.Text; namespace GaudiHTTP.Tests.Shared; public static class FakeResponse { - private static readonly Dictionary ReasonPhrases = new() + private static readonly FrozenDictionary ReasonPhrases = new Dictionary { [200] = "OK", [201] = "Created", [204] = "No Content", [301] = "Moved Permanently", [302] = "Found", [304] = "Not Modified", @@ -12,7 +13,7 @@ public static class FakeResponse [400] = "Bad Request", [401] = "Unauthorized", [403] = "Forbidden", [404] = "Not Found", [429] = "Too Many Requests", [500] = "Internal Server Error", [502] = "Bad Gateway", [503] = "Service Unavailable" - }; + }.ToFrozenDictionary(); private static string GetReason(int status) => ReasonPhrases.GetValueOrDefault(status, "Unknown"); diff --git a/src/GaudiHTTP.Tests.Shared/ResponseMap.cs b/src/GaudiHTTP.Tests.Shared/ResponseMap.cs index b25c86279..f9ad8af19 100644 --- a/src/GaudiHTTP.Tests.Shared/ResponseMap.cs +++ b/src/GaudiHTTP.Tests.Shared/ResponseMap.cs @@ -10,7 +10,7 @@ namespace GaudiHTTP.Tests.Shared; /// public sealed class ResponseMap { - private readonly List<(Func Predicate, Func Factory)> _mappings = []; + private readonly List<(Func Predicate, Func> Factory)> _mappings = []; /// /// Maps a request path to a static response with the given status and body. @@ -25,7 +25,7 @@ public ResponseMap On(string path, HttpStatusCode status, string body) { Content = new StringContent(body) }; - return response; + return Task.FromResult(response); } )); return this; @@ -35,6 +35,17 @@ public ResponseMap On(string path, HttpStatusCode status, string body) /// Maps a request path to a dynamic response produced by the given factory. /// public ResponseMap On(string path, Func factory) + { + _mappings.Add(( + req => string.Equals(req.RequestUri?.AbsolutePath, path, StringComparison.OrdinalIgnoreCase), + req => Task.FromResult(factory(req)))); + return this; + } + + /// + /// Maps a request path to an async response produced by the given factory. + /// + public ResponseMap On(string path, Func> factory) { _mappings.Add(( req => string.Equals(req.RequestUri?.AbsolutePath, path, StringComparison.OrdinalIgnoreCase), @@ -48,20 +59,20 @@ public ResponseMap On(string path, Func /// public ResponseMap On(Func predicate, Func factory) { - _mappings.Add((predicate, factory)); + _mappings.Add((predicate, req => Task.FromResult(factory(req)))); return this; } /// /// Resolves a request to a response. Returns a 404 for unmapped paths. /// - internal HttpResponseMessage Resolve(HttpRequestMessage request) + internal async Task ResolveAsync(HttpRequestMessage request) { foreach (var (predicate, factory) in _mappings) { if (predicate(request)) { - var response = factory(request); + var response = await factory(request); response.RequestMessage = request; return response; } diff --git a/src/GaudiHTTP.Tests.Shared/ResponseMapFake.cs b/src/GaudiHTTP.Tests.Shared/ResponseMapFake.cs index 4ca8c7c22..6d80a7d5e 100644 --- a/src/GaudiHTTP.Tests.Shared/ResponseMapFake.cs +++ b/src/GaudiHTTP.Tests.Shared/ResponseMapFake.cs @@ -48,6 +48,7 @@ private sealed class Logic : GraphStageLogic private readonly ResponseMapFake _stage; private readonly Queue _pendingResponses = new(); private bool _responseRequested; + private Action _onResponseResolved = null!; public Logic(ResponseMapFake stage) : base(stage.Shape) { @@ -58,20 +59,20 @@ public Logic(ResponseMapFake stage) : base(stage.Shape) onPush: () => { var request = Grab(stage._inRequest); - var response = stage._map.Resolve(request); + var task = stage._map.ResolveAsync(request); // Pass request through to outbound (enables downstream stages to see it) Push(stage._outRequest, request); - // Queue the resolved response for the inbound direction - if (_responseRequested) + if (task.IsCompletedSuccessfully) { - _responseRequested = false; - Push(stage._outResponse, response); + EnqueueOrPushResponse(task.Result); } else { - _pendingResponses.Enqueue(response); + task.ContinueWith( + t => _onResponseResolved(t.Result), + TaskContinuationOptions.OnlyOnRanToCompletion); } }, onUpstreamFinish: () => Complete(stage._outRequest), @@ -113,11 +114,27 @@ public Logic(ResponseMapFake stage) : base(stage.Shape) }); } + private void EnqueueOrPushResponse(HttpResponseMessage response) + { + if (_responseRequested) + { + _responseRequested = false; + Push(_stage._outResponse, response); + } + else + { + _pendingResponses.Enqueue(response); + } + } + public override void PreStart() { // Pull inResponse so the downstream flow's output port is not blocked. // Responses from the downstream are discarded — the map generates them. Pull(_stage._inResponse); + + // Callback for async response resolution (thread-safe re-entry into stage logic) + _onResponseResolved = GetAsyncCallback(EnqueueOrPushResponse); } } } diff --git a/src/GaudiHTTP.Tests/Features/Caching/CacheControlParserSpec.cs b/src/GaudiHTTP.Tests/Features/Caching/CacheControlParserSpec.cs index c96917675..e59d56237 100644 --- a/src/GaudiHTTP.Tests/Features/Caching/CacheControlParserSpec.cs +++ b/src/GaudiHTTP.Tests/Features/Caching/CacheControlParserSpec.cs @@ -4,7 +4,7 @@ namespace GaudiHTTP.Tests.Features.Caching; public sealed class CacheControlParserSpec { - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9111-5.2")] public void CacheControlParser_should_return_null_when_input_is_null() { @@ -12,7 +12,7 @@ public void CacheControlParser_should_return_null_when_input_is_null() Assert.Null(result); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9111-5.2")] public void CacheControlParser_should_return_null_when_input_is_empty() { @@ -20,7 +20,7 @@ public void CacheControlParser_should_return_null_when_input_is_empty() Assert.Null(result); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9111-5.2")] public void CacheControlParser_should_return_null_when_input_is_whitespace() { @@ -28,7 +28,7 @@ public void CacheControlParser_should_return_null_when_input_is_whitespace() Assert.Null(result); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9111-5.2")] public void CacheControlParser_should_parse_correctly_when_no_cache_directive() { @@ -38,7 +38,7 @@ public void CacheControlParser_should_parse_correctly_when_no_cache_directive() Assert.Null(result.NoCacheFields); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9111-5.2")] public void CacheControlParser_should_parse_no_store_when_no_store_directive() { @@ -47,7 +47,7 @@ public void CacheControlParser_should_parse_no_store_when_no_store_directive() Assert.True(result.NoStore); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9111-5.2")] public void CacheControlParser_should_parse_max_age_when_max_age_3600() { @@ -56,7 +56,7 @@ public void CacheControlParser_should_parse_max_age_when_max_age_3600() Assert.Equal(TimeSpan.FromSeconds(3600), result.MaxAge); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9111-5.2")] public void CacheControlParser_should_parse_s_max_age_when_s_max_age_600() { @@ -65,7 +65,7 @@ public void CacheControlParser_should_parse_s_max_age_when_s_max_age_600() Assert.Equal(TimeSpan.FromSeconds(600), result.SMaxAge); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9111-5.2")] public void CacheControlParser_should_parse_max_stale_when_max_stale_300() { @@ -74,7 +74,7 @@ public void CacheControlParser_should_parse_max_stale_when_max_stale_300() Assert.Equal(TimeSpan.FromSeconds(300), result.MaxStale); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9111-5.2")] public void CacheControlParser_should_parse_min_fresh_when_min_fresh_60() { @@ -83,7 +83,7 @@ public void CacheControlParser_should_parse_min_fresh_when_min_fresh_60() Assert.Equal(TimeSpan.FromSeconds(60), result.MinFresh); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9111-5.2")] public void CacheControlParser_should_parse_must_revalidate_when_must_revalidate_directive() { @@ -92,7 +92,7 @@ public void CacheControlParser_should_parse_must_revalidate_when_must_revalidate Assert.True(result.MustRevalidate); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9111-5.2")] public void CacheControlParser_should_parse_public_when_public_directive() { @@ -101,7 +101,7 @@ public void CacheControlParser_should_parse_public_when_public_directive() Assert.True(result.Public); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9111-5.2")] public void CacheControlParser_should_parse_private_when_private_directive() { @@ -111,7 +111,7 @@ public void CacheControlParser_should_parse_private_when_private_directive() Assert.Null(result.PrivateFields); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9111-5.2")] public void CacheControlParser_should_parse_immutable_when_immutable_directive() { @@ -120,7 +120,7 @@ public void CacheControlParser_should_parse_immutable_when_immutable_directive() Assert.True(result.Immutable); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9111-5.2")] public void CacheControlParser_should_parse_only_if_cached_when_only_if_cached_directive() { @@ -129,7 +129,7 @@ public void CacheControlParser_should_parse_only_if_cached_when_only_if_cached_d Assert.True(result.OnlyIfCached); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9111-5.2")] public void CacheControlParser_should_parse_all_directives_when_multiple_directives_in_header() { @@ -140,7 +140,7 @@ public void CacheControlParser_should_parse_all_directives_when_multiple_directi Assert.True(result.Public); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9111-5.2")] public void CacheControlParser_should_parse_no_cache_with_field_list_when_no_cache_has_quoted_fields() { @@ -151,7 +151,7 @@ public void CacheControlParser_should_parse_no_cache_with_field_list_when_no_cac Assert.Contains("Authorization", result.NoCacheFields); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9111-5.2")] public void CacheControlParser_should_ignore_unknown_directive_when_unknown_directive_present() { @@ -160,7 +160,7 @@ public void CacheControlParser_should_ignore_unknown_directive_when_unknown_dire Assert.Equal(TimeSpan.FromSeconds(30), result.MaxAge); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9111-5.2")] public void CacheControlParser_should_parse_max_age_when_max_age_is_uppercase() { @@ -169,7 +169,7 @@ public void CacheControlParser_should_parse_max_age_when_max_age_is_uppercase() Assert.Equal(TimeSpan.FromSeconds(3600), result.MaxAge); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9111-5.2")] public void CacheControlParser_should_parse_no_transform_when_no_transform_directive() { @@ -178,7 +178,7 @@ public void CacheControlParser_should_parse_no_transform_when_no_transform_direc Assert.True(result.NoTransform); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9111-5.2")] public void CacheControlParser_should_accept_any_stale_when_max_stale_has_no_value() { @@ -188,7 +188,7 @@ public void CacheControlParser_should_accept_any_stale_when_max_stale_has_no_val } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9111-5.2.2.3")] public void CacheControlParser_should_parse_field_list_when_no_cache_qualified() { @@ -200,7 +200,7 @@ public void CacheControlParser_should_parse_field_list_when_no_cache_qualified() Assert.Equal("Set-Cookie", result.NoCacheFields[0]); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9111-5.2.2.3")] public void CacheControlParser_should_parse_multiple_fields_when_no_cache_qualified() { @@ -213,7 +213,7 @@ public void CacheControlParser_should_parse_multiple_fields_when_no_cache_qualif Assert.Equal("B", result.NoCacheFields[1]); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9111-5.2.2.3")] public void CacheControlParser_should_set_flag_when_unqualified_no_cache() { @@ -223,7 +223,7 @@ public void CacheControlParser_should_set_flag_when_unqualified_no_cache() Assert.Null(result.NoCacheFields); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9111-5.2.2.3")] public void CacheControlParser_should_treat_as_unqualified_when_empty_quotes() { @@ -233,7 +233,7 @@ public void CacheControlParser_should_treat_as_unqualified_when_empty_quotes() Assert.Null(result.NoCacheFields); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9111-5.2.2.3")] public void CacheControlParser_should_parse_field_list_and_other_directives_when_no_cache_with_fields_and_max_age() { @@ -247,7 +247,7 @@ public void CacheControlParser_should_parse_field_list_and_other_directives_when Assert.Equal(TimeSpan.FromSeconds(300), result.MaxAge); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9111-5.2.2.3")] public void CacheControlParser_should_parse_field_when_private_qualified() { @@ -259,7 +259,7 @@ public void CacheControlParser_should_parse_field_when_private_qualified() Assert.Equal("Authorization", result.PrivateFields[0]); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9111-5.2.2.7")] public void CacheControlParser_should_set_flag_when_unqualified_private() { @@ -269,7 +269,7 @@ public void CacheControlParser_should_set_flag_when_unqualified_private() Assert.Null(result.PrivateFields); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9111-5.2.2.7")] public void CacheControlParser_should_parse_multiple_fields_when_private_qualified() { @@ -282,7 +282,7 @@ public void CacheControlParser_should_parse_multiple_fields_when_private_qualifi Assert.Equal("B", result.PrivateFields[1]); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9111-5.2.2.7")] public void CacheControlParser_should_treat_as_unqualified_when_private_empty_quotes() { @@ -292,7 +292,7 @@ public void CacheControlParser_should_treat_as_unqualified_when_private_empty_qu Assert.Null(result.PrivateFields); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9111-5.2.2.7")] public void CacheControlParser_should_parse_field_list_and_other_directives_when_private_with_fields_and_max_age() { diff --git a/src/GaudiHTTP.Tests/Features/Caching/CacheFreshnessSpec.cs b/src/GaudiHTTP.Tests/Features/Caching/CacheFreshnessSpec.cs index 5eba84a8e..acbd13659 100644 --- a/src/GaudiHTTP.Tests/Features/Caching/CacheFreshnessSpec.cs +++ b/src/GaudiHTTP.Tests/Features/Caching/CacheFreshnessSpec.cs @@ -47,7 +47,7 @@ private static CacheEntry MakeEntry( } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9111-4.2")] public void CacheFreshness_should_return_freshness_lifetime_60s_when_max_age_60() { @@ -56,7 +56,7 @@ public void CacheFreshness_should_return_freshness_lifetime_60s_when_max_age_60( Assert.Equal(TimeSpan.FromSeconds(60), lifetime); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9111-4.2")] public void CacheFreshness_should_override_max_age_with_s_max_age_when_shared_cache() { @@ -66,7 +66,7 @@ public void CacheFreshness_should_override_max_age_with_s_max_age_when_shared_ca Assert.Equal(TimeSpan.FromSeconds(120), lifetime); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9111-4.2")] public void CacheFreshness_should_ignore_s_max_age_when_private_cache() { @@ -76,7 +76,7 @@ public void CacheFreshness_should_ignore_s_max_age_when_private_cache() Assert.Equal(TimeSpan.FromSeconds(60), lifetime); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9111-5.3")] public void CacheFreshness_should_use_expires_header_when_no_max_age() { @@ -85,7 +85,7 @@ public void CacheFreshness_should_use_expires_header_when_no_max_age() Assert.Equal(TimeSpan.FromSeconds(300), lifetime); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9111-4.2.2")] public void CacheFreshness_should_use_ten_percent_of_age_when_heuristic_freshness() { @@ -95,7 +95,7 @@ public void CacheFreshness_should_use_ten_percent_of_age_when_heuristic_freshnes Assert.Equal(TimeSpan.FromSeconds(100), lifetime); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9111-4.2.2")] public void CacheFreshness_should_cap_freshness_at_one_day_when_heuristic_freshness_exceeds_one_day() { @@ -105,7 +105,7 @@ public void CacheFreshness_should_cap_freshness_at_one_day_when_heuristic_freshn Assert.Equal(TimeSpan.FromDays(1), lifetime); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9111-4.2")] public void CacheFreshness_should_return_lifetime_zero_when_no_freshness_info() { @@ -114,7 +114,7 @@ public void CacheFreshness_should_return_lifetime_zero_when_no_freshness_info() Assert.Equal(TimeSpan.Zero, lifetime); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9111-4.2.3")] public void CacheFreshness_should_use_age_header_when_computing_current_age() { @@ -126,7 +126,7 @@ public void CacheFreshness_should_use_age_header_when_computing_current_age() Assert.Equal(TimeSpan.FromSeconds(41), age); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9111-4.2.3")] public void CacheFreshness_should_use_response_delay_when_no_age_header() { @@ -142,7 +142,7 @@ public void CacheFreshness_should_use_response_delay_when_no_age_header() } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9111-4.2")] public void CacheFreshness_should_return_is_fresh_true_when_freshness_lifetime_exceeds_current_age() { @@ -151,7 +151,7 @@ public void CacheFreshness_should_return_is_fresh_true_when_freshness_lifetime_e Assert.True(CacheFreshnessEvaluator.IsFresh(entry, now)); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9111-4.2")] public void CacheFreshness_should_return_is_fresh_false_when_current_age_exceeds_freshness_lifetime() { @@ -160,7 +160,7 @@ public void CacheFreshness_should_return_is_fresh_false_when_current_age_exceeds Assert.False(CacheFreshnessEvaluator.IsFresh(entry, now)); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9111-4")] public void CacheFreshness_should_return_miss_when_entry_is_null() { @@ -169,7 +169,7 @@ public void CacheFreshness_should_return_miss_when_entry_is_null() Assert.Equal(CacheLookupStatus.Miss, result.Status); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9111-4")] public void CacheFreshness_should_return_fresh_when_entry_is_fresh() { @@ -180,7 +180,7 @@ public void CacheFreshness_should_return_fresh_when_entry_is_fresh() Assert.Equal(CacheLookupStatus.Fresh, result.Status); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9111-5.1")] public void CacheFreshness_should_add_age_header_when_serving_from_cache() { @@ -193,7 +193,7 @@ public void CacheFreshness_should_add_age_header_when_serving_from_cache() Assert.True(response.Headers.Contains("Age")); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9111-5.1")] public void CacheFreshness_should_match_current_age_when_age_header_generated() { @@ -208,7 +208,7 @@ public void CacheFreshness_should_match_current_age_when_age_header_generated() Assert.Equal(((long)expectedAge.TotalSeconds).ToString(), ageValue); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9111-5.1")] public void CacheFreshness_should_overwrite_age_when_already_present() { @@ -227,7 +227,7 @@ public void CacheFreshness_should_overwrite_age_when_already_present() Assert.Equal(expectedAge.ToString(), values[0]); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9111-5.2.1.4")] public void Evaluate_should_return_must_revalidate_when_request_no_cache() { @@ -241,7 +241,7 @@ public void Evaluate_should_return_must_revalidate_when_request_no_cache() Assert.Equal(CacheLookupStatus.MustRevalidate, result.Status); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9111-5.2.2.3")] public void Evaluate_should_return_must_revalidate_when_response_unqualified_no_cache() { @@ -266,7 +266,7 @@ public void Evaluate_should_return_must_revalidate_when_response_unqualified_no_ Assert.Equal(CacheLookupStatus.MustRevalidate, result.Status); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9111-5.2.1.3")] public void Evaluate_should_return_stale_when_request_min_fresh_not_satisfied() { @@ -280,7 +280,7 @@ public void Evaluate_should_return_stale_when_request_min_fresh_not_satisfied() Assert.Equal(CacheLookupStatus.MustRevalidate, result.Status); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9111-5.2.2.2")] public void Evaluate_should_return_must_revalidate_when_stale_and_must_revalidate_set() { @@ -305,7 +305,7 @@ public void Evaluate_should_return_must_revalidate_when_stale_and_must_revalidat Assert.Equal(CacheLookupStatus.MustRevalidate, result.Status); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9111-5.2.2.2")] public void Evaluate_should_return_must_revalidate_when_stale_proxy_and_proxy_revalidate_in_shared_cache() { @@ -331,7 +331,7 @@ public void Evaluate_should_return_must_revalidate_when_stale_proxy_and_proxy_re Assert.Equal(CacheLookupStatus.MustRevalidate, result.Status); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9111-5.2.1.2")] public void Evaluate_should_return_stale_when_request_max_stale_with_sufficient_tolerance() { @@ -345,7 +345,7 @@ public void Evaluate_should_return_stale_when_request_max_stale_with_sufficient_ Assert.Equal(CacheLookupStatus.Stale, result.Status); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9111-5.2.1.2")] public void Evaluate_should_return_must_revalidate_when_stale_exceeds_max_stale_tolerance() { diff --git a/src/GaudiHTTP.Tests/Features/Caching/CacheInvalidationSpec.cs b/src/GaudiHTTP.Tests/Features/Caching/CacheInvalidationSpec.cs index 514820fb7..cf8b72574 100644 --- a/src/GaudiHTTP.Tests/Features/Caching/CacheInvalidationSpec.cs +++ b/src/GaudiHTTP.Tests/Features/Caching/CacheInvalidationSpec.cs @@ -34,7 +34,7 @@ private static bool IsSameOrigin(Uri requestUri, Uri targetUri) [Trait("RFC", "RFC9111-4.4")] - [Fact] + [Fact(Timeout = 5000)] public void CacheInvalidation_should_invalidate_when_post_with_location() { var targetUri = "http://example.com/created-resource"; @@ -47,7 +47,7 @@ public void CacheInvalidation_should_invalidate_when_post_with_location() } [Trait("RFC", "RFC9111-4.4")] - [Fact] + [Fact(Timeout = 5000)] public void CacheInvalidation_should_invalidate_when_put_with_content_location() { var targetUri = "http://example.com/updated-resource"; @@ -60,7 +60,7 @@ public void CacheInvalidation_should_invalidate_when_put_with_content_location() } [Trait("RFC", "RFC9111-4.4")] - [Fact] + [Fact(Timeout = 5000)] public void CacheInvalidation_should_not_invalidate_when_cross_origin_location() { var store = CreateStoreWithEntry(); @@ -75,7 +75,7 @@ public void CacheInvalidation_should_not_invalidate_when_cross_origin_location() } [Trait("RFC", "RFC9111-4.4")] - [Fact] + [Fact(Timeout = 5000)] public void CacheInvalidation_should_not_invalidate_when_safe_method() { var store = CreateStoreWithEntry(); @@ -87,7 +87,7 @@ public void CacheInvalidation_should_not_invalidate_when_safe_method() } [Trait("RFC", "RFC9111-4.4")] - [Fact] + [Fact(Timeout = 5000)] public void CacheInvalidation_should_not_invalidate_when_error_response() { var store = CreateStoreWithEntry(); @@ -100,7 +100,7 @@ public void CacheInvalidation_should_not_invalidate_when_error_response() } [Trait("RFC", "RFC9111-4.4")] - [Fact] + [Fact(Timeout = 5000)] public void CacheInvalidation_should_invalidate_both_uris_when_delete_with_location() { var requestUri = "http://example.com/item/42"; @@ -126,7 +126,7 @@ public void CacheInvalidation_should_invalidate_both_uris_when_delete_with_locat } [Trait("RFC", "RFC9111-4.4")] - [Fact] + [Fact(Timeout = 5000)] public void CacheInvalidation_should_invalidate_when_same_origin_different_path() { var requestUri = new Uri("http://example.com/action"); @@ -136,7 +136,7 @@ public void CacheInvalidation_should_invalidate_when_same_origin_different_path( } [Trait("RFC", "RFC9111-4.4")] - [Fact] + [Fact(Timeout = 5000)] public void CacheInvalidation_should_not_invalidate_when_different_port() { var requestUri = new Uri("http://example.com:80/action"); @@ -146,7 +146,7 @@ public void CacheInvalidation_should_not_invalidate_when_different_port() } [Trait("RFC", "RFC9111-4.4")] - [Fact] + [Fact(Timeout = 5000)] public void CacheInvalidation_should_not_invalidate_when_different_scheme() { var requestUri = new Uri("http://example.com/action"); @@ -156,7 +156,7 @@ public void CacheInvalidation_should_not_invalidate_when_different_scheme() } [Trait("RFC", "RFC9111-4.4")] - [Fact] + [Fact(Timeout = 5000)] public void CacheInvalidation_should_resolve_relative_location_against_request_uri() { var requestUri = new Uri("http://example.com/api/action"); diff --git a/src/GaudiHTTP.Tests/Features/Caching/CacheQualifiedDirectiveSpec.cs b/src/GaudiHTTP.Tests/Features/Caching/CacheQualifiedDirectiveSpec.cs index c82e0a573..9c3127f30 100644 --- a/src/GaudiHTTP.Tests/Features/Caching/CacheQualifiedDirectiveSpec.cs +++ b/src/GaudiHTTP.Tests/Features/Caching/CacheQualifiedDirectiveSpec.cs @@ -26,7 +26,7 @@ private static void Put(Cache store, HttpRequestMessage request, HttpResponseMes } [Trait("RFC", "RFC9111-5.2.2.3")] - [Fact] + [Fact(Timeout = 5000)] public void CacheQualifiedDirective_should_strip_field_when_no_cache_qualified() { var store = new Cache(); @@ -49,7 +49,7 @@ public void CacheQualifiedDirective_should_strip_field_when_no_cache_qualified() } [Trait("RFC", "RFC9111-5.2.2.3")] - [Fact] + [Fact(Timeout = 5000)] public void CacheQualifiedDirective_should_strip_multiple_fields_when_no_cache_qualified() { var store = new Cache(); @@ -71,7 +71,7 @@ public void CacheQualifiedDirective_should_strip_multiple_fields_when_no_cache_q } [Trait("RFC", "RFC9111-5.2.2.3")] - [Fact] + [Fact(Timeout = 5000)] public void CacheQualifiedDirective_should_require_revalidation_when_unqualified_no_cache() { var response = OkResponseWithCacheControl("max-age=3600, no-cache"); @@ -94,7 +94,7 @@ public void CacheQualifiedDirective_should_require_revalidation_when_unqualified } [Trait("RFC", "RFC9111-5.2.2.3")] - [Fact] + [Fact(Timeout = 5000)] public void CacheQualifiedDirective_should_not_force_revalidation_when_no_cache_qualified() { var response = OkResponseWithCacheControl("max-age=3600, no-cache=\"Set-Cookie\""); @@ -118,7 +118,7 @@ public void CacheQualifiedDirective_should_not_force_revalidation_when_no_cache_ } [Trait("RFC", "RFC9111-5.2.2.7")] - [Fact] + [Fact(Timeout = 5000)] public void CacheQualifiedDirective_should_exclude_field_when_private_qualified_in_shared_cache() { var policy = new CachePolicy { SharedCache = true }; @@ -141,7 +141,7 @@ public void CacheQualifiedDirective_should_exclude_field_when_private_qualified_ } [Trait("RFC", "RFC9111-5.2.2.7")] - [Fact] + [Fact(Timeout = 5000)] public void CacheQualifiedDirective_should_not_store_when_unqualified_private_in_shared_cache() { var policy = new CachePolicy { SharedCache = true }; @@ -158,7 +158,7 @@ public void CacheQualifiedDirective_should_not_store_when_unqualified_private_in } [Trait("RFC", "RFC9111-5.2.2.7")] - [Fact] + [Fact(Timeout = 5000)] public void CacheQualifiedDirective_should_store_when_unqualified_private_in_private_cache() { var policy = new CachePolicy { SharedCache = false }; diff --git a/src/GaudiHTTP.Tests/Features/Caching/CacheSpec.cs b/src/GaudiHTTP.Tests/Features/Caching/CacheSpec.cs index 40fde5d5c..a8a7b9897 100644 --- a/src/GaudiHTTP.Tests/Features/Caching/CacheSpec.cs +++ b/src/GaudiHTTP.Tests/Features/Caching/CacheSpec.cs @@ -26,7 +26,7 @@ private static HttpResponseMessage OkResponse(int maxAge = 60) } [Trait("RFC", "RFC9111-3.1")] - [Fact] + [Fact(Timeout = 5000)] public void CacheStore_should_be_cacheable_when_200_ok_with_max_age() { var response = OkResponse(); @@ -51,7 +51,7 @@ public void CacheStore_should_be_cacheable_when_status_code_is_cacheable(int sta } [Trait("RFC", "RFC9111-3.1")] - [Fact] + [Fact(Timeout = 5000)] public void CacheStore_should_not_be_cacheable_when_500_internal_server_error() { var response = new HttpResponseMessage(HttpStatusCode.InternalServerError); @@ -60,14 +60,14 @@ public void CacheStore_should_not_be_cacheable_when_500_internal_server_error() [Trait("RFC", "RFC9111-3")] - [Fact] + [Fact(Timeout = 5000)] public void CacheStore_should_store_entry_when_get_200_with_max_age() { Assert.True(Cache.ShouldStore(GetRequest(), OkResponse())); } [Trait("RFC", "RFC9111-3")] - [Fact] + [Fact(Timeout = 5000)] public void CacheStore_should_not_store_entry_when_post_200_unsafe_method() { var post = new HttpRequestMessage(HttpMethod.Post, "http://example.com/resource"); @@ -75,7 +75,7 @@ public void CacheStore_should_not_store_entry_when_post_200_unsafe_method() } [Trait("RFC", "RFC9111-5.2.1.5")] - [Fact] + [Fact(Timeout = 5000)] public void CacheStore_should_not_store_entry_when_request_has_no_store() { var request = GetRequest(); @@ -84,7 +84,7 @@ public void CacheStore_should_not_store_entry_when_request_has_no_store() } [Trait("RFC", "RFC9111-5.2.2.5")] - [Fact] + [Fact(Timeout = 5000)] public void CacheStore_should_not_store_entry_when_response_has_no_store() { var response = new HttpResponseMessage(HttpStatusCode.OK); @@ -94,7 +94,7 @@ public void CacheStore_should_not_store_entry_when_response_has_no_store() [Trait("RFC", "RFC9111-4")] - [Fact] + [Fact(Timeout = 5000)] public void CacheStore_should_return_null_when_store_is_empty() { var store = new Cache(); @@ -103,7 +103,7 @@ public void CacheStore_should_return_null_when_store_is_empty() } [Trait("RFC", "RFC9111-3")] - [Fact] + [Fact(Timeout = 5000)] public void CacheStore_should_return_cached_entry_when_put_then_get_same_uri() { var store = new Cache(); @@ -119,7 +119,7 @@ public void CacheStore_should_return_cached_entry_when_put_then_get_same_uri() } [Trait("RFC", "RFC9111-4.4")] - [Fact] + [Fact(Timeout = 5000)] public void CacheStore_should_remove_entry_when_invalidated() { var store = new Cache(); @@ -133,7 +133,7 @@ public void CacheStore_should_remove_entry_when_invalidated() [Trait("RFC", "RFC9111-4.1")] - [Fact] + [Fact(Timeout = 5000)] public void CacheStore_should_return_miss_when_vary_header_and_different_accept() { var store = new Cache(); @@ -153,7 +153,7 @@ public void CacheStore_should_return_miss_when_vary_header_and_different_accept( } [Trait("RFC", "RFC9111-4.1")] - [Fact] + [Fact(Timeout = 5000)] public void CacheStore_should_return_hit_when_vary_header_and_matching_accept() { var store = new Cache(); @@ -174,7 +174,7 @@ public void CacheStore_should_return_hit_when_vary_header_and_matching_accept() } [Trait("RFC", "RFC9111-4.1")] - [Fact] + [Fact(Timeout = 5000)] public void CacheStore_should_never_match_when_vary_is_star() { var store = new Cache(); @@ -189,7 +189,7 @@ public void CacheStore_should_never_match_when_vary_is_star() [Trait("RFC", "RFC9111-5.2.2.3")] - [Fact] + [Fact(Timeout = 5000)] public void CacheStore_should_store_when_must_understand_and_200() { var request = GetRequest(); @@ -201,7 +201,7 @@ public void CacheStore_should_store_when_must_understand_and_200() } [Trait("RFC", "RFC9111-5.2.2.3")] - [Fact] + [Fact(Timeout = 5000)] public void CacheStore_should_not_store_when_must_understand_and_unknown_status() { var request = GetRequest(); @@ -213,7 +213,7 @@ public void CacheStore_should_not_store_when_must_understand_and_unknown_status( } [Trait("RFC", "RFC9111-5.2.2.3")] - [Fact] + [Fact(Timeout = 5000)] public void CacheStore_should_store_when_no_must_understand() { var request = GetRequest(); @@ -226,7 +226,7 @@ public void CacheStore_should_store_when_no_must_understand() } [Trait("RFC", "RFC9111-3.1")] - [Fact] + [Fact(Timeout = 5000)] public void CacheStore_should_not_store_when_206_partial_content() { var request = GetRequest(); @@ -238,7 +238,7 @@ public void CacheStore_should_not_store_when_206_partial_content() } [Trait("RFC", "RFC9111-3.1")] - [Fact] + [Fact(Timeout = 5000)] public void CacheStore_should_not_store_when_response_has_content_range() { var request = GetRequest(); @@ -253,7 +253,7 @@ public void CacheStore_should_not_store_when_response_has_content_range() } [Trait("RFC", "RFC9111-3.1")] - [Fact] + [Fact(Timeout = 5000)] public void CacheStore_should_store_when_200_without_content_range() { var request = GetRequest(); @@ -263,7 +263,7 @@ public void CacheStore_should_store_when_200_without_content_range() } [Trait("RFC", "RFC9111-3.1")] - [Fact] + [Fact(Timeout = 5000)] public void CacheStore_should_not_merge_trailers_when_cached_with_trailers() { var store = new Cache(); @@ -293,7 +293,7 @@ public void CacheStore_should_not_merge_trailers_when_cached_with_trailers() } [Trait("RFC", "RFC9111-3.1")] - [Fact] + [Fact(Timeout = 5000)] public void CacheStore_should_not_store_connection_header_when_connection_header() { var store = new Cache(); @@ -337,7 +337,7 @@ public void CacheStore_should_not_store_connection_specific_header(string header } [Trait("RFC", "RFC9111-3.1")] - [Fact] + [Fact(Timeout = 5000)] public void CacheStore_should_store_custom_headers() { var store = new Cache(); @@ -363,7 +363,7 @@ public void CacheStore_should_store_custom_headers() } [Trait("RFC", "RFC9111-3")] - [Fact] + [Fact(Timeout = 5000)] public void CacheStore_should_evict_entries_when_max_entries_exceeded() { var policy = new CachePolicy { MaxEntries = 2 }; diff --git a/src/GaudiHTTP.Tests/Features/Cookies/CookieSecuritySpec.cs b/src/GaudiHTTP.Tests/Features/Cookies/CookieSecuritySpec.cs index a73215631..1f4572f06 100644 --- a/src/GaudiHTTP.Tests/Features/Cookies/CookieSecuritySpec.cs +++ b/src/GaudiHTTP.Tests/Features/Cookies/CookieSecuritySpec.cs @@ -32,7 +32,7 @@ private static HttpResponseMessage ResponseWithCookie(string setCookie) : null; } - [Fact] + [Fact(Timeout = 5000)] public void CookieJar_should_not_send_secure_cookie_when_request_is_http() { // Attack: MITM intercepts plaintext HTTP and reads Secure-flagged cookies. @@ -46,7 +46,7 @@ public void CookieJar_should_not_send_secure_cookie_when_request_is_http() Assert.Null(cookie); } - [Fact] + [Fact(Timeout = 5000)] public void CookieJar_should_send_secure_cookie_when_request_is_https() { // Verify that Secure cookies are correctly delivered over HTTPS. @@ -61,7 +61,7 @@ public void CookieJar_should_send_secure_cookie_when_request_is_https() Assert.Contains("token=secret", cookie); } - [Fact] + [Fact(Timeout = 5000)] public void CookieJar_should_send_non_secure_cookie_when_any_scheme() { // Non-Secure cookies have no transport restriction. @@ -77,7 +77,7 @@ public void CookieJar_should_send_non_secure_cookie_when_any_scheme() Assert.Contains("sid=abc", httpsCookie); } - [Fact] + [Fact(Timeout = 5000)] public void CookieJar_should_store_httponly_flag_when_set_cookie_contains_httponly() { // HttpOnly is [Fact(Timeout = 5000)] server-enforced attribute; the client stores it for informational purposes. @@ -96,7 +96,7 @@ public void CookieJar_should_store_httponly_flag_when_set_cookie_contains_httpon // SameSite — Cross-site request scoping - [Fact] + [Fact(Timeout = 5000)] public void CookieJar_should_store_samesite_strict_when_set_cookie_contains_strict() { // SameSite=Strict cookies are stored and sent on same-site requests. Cross-site exclusion @@ -113,7 +113,7 @@ public void CookieJar_should_store_samesite_strict_when_set_cookie_contains_stri Assert.Contains("csrf=token123", cookie); } - [Fact] + [Fact(Timeout = 5000)] public void CookieJar_should_store_samesite_lax_when_set_cookie_contains_lax() { // SameSite=Lax cookies are sent on safe top-level navigations (GET) but not @@ -129,7 +129,7 @@ public void CookieJar_should_store_samesite_lax_when_set_cookie_contains_lax() Assert.Contains("pref=dark", cookie); } - [Fact] + [Fact(Timeout = 5000)] public void CookieJar_should_not_send_strict_cookie_when_request_is_cross_site() { // Attack: a cross-site request (initiated by other.com) must not carry a SameSite=Strict @@ -144,7 +144,7 @@ public void CookieJar_should_not_send_strict_cookie_when_request_is_cross_site() Assert.Null(cookie); } - [Fact] + [Fact(Timeout = 5000)] public void CookieJar_should_send_strict_cookie_when_request_is_same_site() { // Same-site request (initiated by example.com) carries the Strict cookie. @@ -159,7 +159,7 @@ public void CookieJar_should_send_strict_cookie_when_request_is_same_site() Assert.Contains("csrf=token123", cookie); } - [Fact] + [Fact(Timeout = 5000)] public void CookieJar_should_not_send_lax_cookie_when_cross_site_unsafe_method() { // SameSite=Lax cookies are withheld on cross-site unsafe (POST) requests. @@ -173,7 +173,7 @@ public void CookieJar_should_not_send_lax_cookie_when_cross_site_unsafe_method() Assert.Null(cookie); } - [Fact] + [Fact(Timeout = 5000)] public void CookieJar_should_send_lax_cookie_when_cross_site_safe_method() { // SameSite=Lax cookies ARE sent on cross-site safe top-level navigations (GET). @@ -188,7 +188,7 @@ public void CookieJar_should_send_lax_cookie_when_cross_site_safe_method() Assert.Contains("pref=dark", cookie); } - [Fact] + [Fact(Timeout = 5000)] public void CookieJar_should_send_none_cookie_when_cross_site() { // SameSite=None (with Secure) is intended for cross-site use and is always sent. @@ -203,7 +203,7 @@ public void CookieJar_should_send_none_cookie_when_cross_site() Assert.Contains("tracker=abc", cookie); } - [Fact] + [Fact(Timeout = 5000)] public void CookieJar_should_treat_subdomain_as_same_site_for_strict_cookie() { // Same registrable domain (app.example.com vs api.example.com) is same-site: @@ -219,7 +219,7 @@ public void CookieJar_should_treat_subdomain_as_same_site_for_strict_cookie() Assert.Contains("csrf=token123", cookie); } - [Fact] + [Fact(Timeout = 5000)] public void CookieJar_should_store_samesite_none_when_set_cookie_contains_none() { // SameSite=None is used for cross-site cookies and requires Secure per browser policy. @@ -234,7 +234,7 @@ public void CookieJar_should_store_samesite_none_when_set_cookie_contains_none() Assert.Contains("tracker=abc", cookie); } - [Fact] + [Fact(Timeout = 5000)] public void CookieJar_should_not_send_subdomain_cookie_when_request_to_parent_domain() { // Attack: [Fact(Timeout = 5000)] cookie set by sub.example.com (host-only) should not be leaked @@ -250,7 +250,7 @@ public void CookieJar_should_not_send_subdomain_cookie_when_request_to_parent_do Assert.Null(cookie); } - [Fact] + [Fact(Timeout = 5000)] public void CookieJar_should_send_domain_cookie_to_subdomain_when_domain_attribute_set() { // Domain=example.com allows subdomains but must not leak to notexample.com. @@ -267,7 +267,7 @@ public void CookieJar_should_send_domain_cookie_to_subdomain_when_domain_attribu Assert.Null(unrelatedCookie); } - [Fact] + [Fact(Timeout = 5000)] public void CookieJar_should_not_match_cookie_when_domain_is_substring_but_not_label_boundary() { // Attack: "notexample.com" ends with "example.com" as [Fact(Timeout = 5000)] string, but the cookie @@ -277,7 +277,7 @@ public void CookieJar_should_not_match_cookie_when_domain_is_substring_but_not_l Assert.False(result); } - [Fact] + [Fact(Timeout = 5000)] public void CookieJar_should_reject_domain_match_when_request_host_is_ip_address() { // Attack: IP addresses cannot be subdomains. Prevents scope escalation via IP. @@ -286,7 +286,7 @@ public void CookieJar_should_reject_domain_match_when_request_host_is_ip_address Assert.False(result); } - [Fact] + [Fact(Timeout = 5000)] public void CookieJar_should_not_send_host_only_cookie_when_request_to_subdomain() { // Host-only cookies (no Domain attribute) require exact match. @@ -300,7 +300,7 @@ public void CookieJar_should_not_send_host_only_cookie_when_request_to_subdomain Assert.Null(subCookie); } - [Fact] + [Fact(Timeout = 5000)] public void CookieJar_should_reject_cookie_when_domain_attribute_does_not_match_request_host() { // Attack: evil.com sets Domain=example.com to hijack cookies. @@ -312,7 +312,7 @@ public void CookieJar_should_reject_cookie_when_domain_attribute_does_not_match_ Assert.Equal(0, jar.Count); } - [Fact] + [Fact(Timeout = 5000)] public void CookieJar_should_not_send_cookie_when_request_path_outside_cookie_path() { // Cookie scoped to /foo must not leak to /bar. @@ -326,7 +326,7 @@ public void CookieJar_should_not_send_cookie_when_request_path_outside_cookie_pa Assert.Null(barCookie); } - [Fact] + [Fact(Timeout = 5000)] public void CookieJar_should_send_cookie_when_request_path_is_subpath_of_cookie_path() { // /foo cookie matches /foo/sub (boundary at '/'). @@ -341,7 +341,7 @@ public void CookieJar_should_send_cookie_when_request_path_is_subpath_of_cookie_ Assert.Contains("scoped=val", cookie); } - [Fact] + [Fact(Timeout = 5000)] public void CookieJar_should_not_send_cookie_when_request_path_shares_prefix_but_not_boundary() { // /foobar starts with /foo but does not have a label boundary at position 4. @@ -350,7 +350,7 @@ public void CookieJar_should_not_send_cookie_when_request_path_shares_prefix_but Assert.False(result); } - [Fact] + [Fact(Timeout = 5000)] public void CookieJar_should_not_match_root_when_path_contains_traversal() { // Attack: /foo/.. should not collapse to / and bypass path scoping. @@ -380,7 +380,7 @@ public void CookieJar_should_not_match_root_when_path_contains_traversal() Assert.False(fooResult); } - [Fact] + [Fact(Timeout = 5000)] public void CookieJar_should_match_foo_cookie_when_uri_normalizes_traversal_to_foo() { // The System.Uri class normalizes /bar/../foo → /foo before cookie matching. @@ -401,7 +401,7 @@ public void CookieJar_should_match_foo_cookie_when_uri_normalizes_traversal_to_f Assert.False(CookieJar.PathMatches("/foo", "/bar/../foo")); } - [Fact] + [Fact(Timeout = 5000)] public void CookieJar_should_delete_cookie_when_max_age_is_zero() { // Max-Age=0 signals immediate deletion. Verifies cookie is removed from jar. @@ -419,7 +419,7 @@ public void CookieJar_should_delete_cookie_when_max_age_is_zero() Assert.Equal(0, jar.Count); } - [Fact] + [Fact(Timeout = 5000)] public void CookieJar_should_not_store_cookie_when_max_age_is_zero() { // A new cookie with Max-Age=0 should not be stored at all. @@ -431,7 +431,7 @@ public void CookieJar_should_not_store_cookie_when_max_age_is_zero() Assert.Equal(0, jar.Count); } - [Fact] + [Fact(Timeout = 5000)] public void CookieJar_should_not_store_cookie_when_max_age_is_negative() { // Negative Max-Age should be treated as expired (same as Max-Age=0). @@ -443,7 +443,7 @@ public void CookieJar_should_not_store_cookie_when_max_age_is_negative() Assert.Equal(0, jar.Count); } - [Fact] + [Fact(Timeout = 5000)] public void CookieJar_should_handle_gracefully_when_cookie_value_is_extremely_large() { // Attack: Adversary sends a cookie with a very large value to cause OOM or slowdowns. @@ -459,7 +459,7 @@ public void CookieJar_should_handle_gracefully_when_cookie_value_is_extremely_la Assert.Equal(1, jar.Count); } - [Fact] + [Fact(Timeout = 5000)] public void CookieJar_should_handle_gracefully_when_cookie_name_is_extremely_large() { // Attack: Adversary sends a cookie with a very large name. @@ -473,7 +473,7 @@ public void CookieJar_should_handle_gracefully_when_cookie_name_is_extremely_lar Assert.Equal(1, jar.Count); } - [Fact] + [Fact(Timeout = 5000)] public void CookieJar_should_handle_gracefully_when_many_cookies_stored() { // Attack: Adversary floods the jar with thousands of cookies to cause performance degradation. @@ -492,7 +492,7 @@ public void CookieJar_should_handle_gracefully_when_many_cookies_stored() Assert.NotNull(cookie); } - [Fact] + [Fact(Timeout = 5000)] public void CookieJar_should_store_all_security_attributes_when_combined_on_single_cookie() { // Verify that all security attributes are stored when combined. @@ -512,7 +512,7 @@ public void CookieJar_should_store_all_security_attributes_when_combined_on_sing Assert.Null(httpCookie); } - [Fact] + [Fact(Timeout = 5000)] public void CookieJar_should_enforce_combined_scoping_when_domain_and_path_both_set() { // Cookie must match both domain AND path to be sent. diff --git a/src/GaudiHTTP.Tests/Protocol/Body/BodyPumpBaseSpec.cs b/src/GaudiHTTP.Tests/Protocol/Body/BodyPumpBaseSpec.cs index 4169917c8..91a721f58 100644 --- a/src/GaudiHTTP.Tests/Protocol/Body/BodyPumpBaseSpec.cs +++ b/src/GaudiHTTP.Tests/Protocol/Body/BodyPumpBaseSpec.cs @@ -315,6 +315,53 @@ public void Budget_should_decrease_from_fast_to_slow_after_pause() $"Expected budget to decrease below {budgetAfterFast} after a slow interval."); } + // Fix 3: AddCreditWithoutEma + + [Fact(Timeout = 5000)] + public void AddCreditWithoutEma_should_not_alter_budget() + { + var target = new FakeTarget(); + var pump = new TestPump(target, new ConnectionPoolContext(), new CancellationTokenSource()); + + var budgetBefore = pump.Budget; + + for (var i = 0; i < 50; i++) + { + pump.AddCreditWithoutEma(); + } + + Assert.Equal(budgetBefore, pump.Budget); + } + + [Fact(Timeout = 5000)] + public void AddCredit_during_DrainReady_should_skip_EMA_update() + { + // When AddCredit is called inline from EmitDataFrames during DrainReady, + // the EMA should NOT be updated (the interval is sub-microsecond and + // would distort the budget toward MaxBudget). Verify that a slow budget + // established before the drain is preserved after. + var target = new InlineCreditFakeTarget { HasPendingDemand = false }; + var pump = new TestPump(target, new ConnectionPoolContext(), new CancellationTokenSource()); + target.SetPump(pump); + + // Establish slow budget. + for (var i = 0; i < 5; i++) + { + Thread.Sleep(15); + pump.AddCredit(); + } + + var slowBudget = pump.Budget; + Assert.Equal(8, slowBudget); + + // Register a stream and drain it. The inline AddCredit calls from + // EmitDataFrames happen inside DrainReady → EMA is skipped. + pump.Register(0, MakeBody(3 * 16 * 1024), CancellationToken.None, initialCredits: 16); + + Assert.Single(target.Completed); + Assert.Equal(slowBudget, pump.Budget); + } + // Completion-trigger [Fact(Timeout = 5000)] @@ -791,6 +838,73 @@ public override ValueTask ReadAsync(Memory buffer, CancellationToken => throw new IOException("sync throw from ReadAsync"); } + [Fact(Timeout = 5000)] + public void InlineAddCredit_with_many_concurrent_sync_streams_should_drain_all_iteratively() + { + // Before the re-entrancy guard: many sync streams with inline AddCredit from + // EmitDataFrames would produce O(N) recursive DrainReady calls on the stack, + // risking StackOverflowException. After the fix, DrainReady converts recursive + // stack growth to flat iteration — same total work, O(1) stack depth. + // Each stream gets 16 initial credits (matches production EncodeRequest path), + // which is enough to drain each small body during registration. + var target = new InlineCreditFakeTarget { HasPendingDemand = false }; + var pump = new TestPump(target, new ConnectionPoolContext(), new CancellationTokenSource()); + target.SetPump(pump); + + for (var i = 0; i < 200; i++) + { + pump.Register(i, MakeBody(100), CancellationToken.None, initialCredits: 16); + } + + Assert.Equal(200, target.Completed.Count); + } + + [Fact(Timeout = 5000)] + public void InlineAddCredit_with_multichunk_bodies_should_drain_all_iteratively() + { + // Each stream has a 3-chunk body (3 × 16 KB = 4 reads including EOF). + // With inline AddCredit, the pre-fix recursion depth was O(reads) per stream. + // After the fix, DrainReady iterates flat. + var target = new InlineCreditFakeTarget { HasPendingDemand = false }; + var pump = new TestPump(target, new ConnectionPoolContext(), new CancellationTokenSource()); + target.SetPump(pump); + + for (var i = 0; i < 200; i++) + { + pump.Register(i, MakeBody(3 * 16 * 1024), CancellationToken.None, initialCredits: 16); + } + + Assert.Equal(200, target.Completed.Count); + var totalBytes = target.Emitted.Where(e => !e.EndStream).Sum(e => e.Data.Length); + Assert.Equal(200 * 3 * 16 * 1024, totalBytes); + } + + [Fact(Timeout = 5000)] + public void InlineAddCredit_with_queued_streams_should_drain_all_via_external_credits() + { + // Registers 50 streams with NO initial credits, then injects credits externally. + // Each 100-byte stream needs 2 reads (data + EOF). Inline AddCredit from + // EmitDataFrames reclaims the data credit, so each stream consumes 1 net credit + // for the EOF. With enough external credits, all streams drain. + var target = new InlineCreditFakeTarget { HasPendingDemand = false }; + var pump = new TestPump(target, new ConnectionPoolContext(), new CancellationTokenSource()); + target.SetPump(pump); + + for (var i = 0; i < 50; i++) + { + pump.Register(i, MakeBody(100), CancellationToken.None); + } + + Assert.Empty(target.Completed); + + for (var i = 0; i < 100; i++) + { + pump.AddCredit(); + } + + Assert.Equal(50, target.Completed.Count); + } + [Fact(Timeout = 5000)] public void ReadAsync_that_throws_synchronously_should_propagate_as_exception() { diff --git a/src/GaudiHTTP.Tests/Protocol/Body/FlowControlledBodyPumpSpec.cs b/src/GaudiHTTP.Tests/Protocol/Body/FlowControlledBodyPumpSpec.cs index 32f0b5a19..9d9d9b9b6 100644 --- a/src/GaudiHTTP.Tests/Protocol/Body/FlowControlledBodyPumpSpec.cs +++ b/src/GaudiHTTP.Tests/Protocol/Body/FlowControlledBodyPumpSpec.cs @@ -840,4 +840,292 @@ public void OnWindowUpdate_connection_level_should_unblock_only_non_cancelled_bl Assert.Contains(3, target.Completed); Assert.DoesNotContain(target.Emitted, e => e.StreamId == 2 && !e.EndStream); } + + // Fix 2: Scaled WINDOW_UPDATE credit injection + + [Fact(Timeout = 5000)] + public void OnWindowUpdate_connection_level_should_drain_blocked_streams_with_scaled_credits() + { + // 5 streams all window-blocked. Connection-level WINDOW_UPDATE should inject + // credits proportional to unblocked count (5, capped at 16) and drain all bodies. + // The pump has accumulated credits from initialCredits during registration. + var target = new FakeTarget(); + var flow = MakeFlow(connWindow: 1024 * 1024); + var pump = MakePump(target, flow); + + for (var id = 1; id <= 10; id += 2) + { + flow.InitStreamSendWindow(id); + flow.OnDataSent(id, 65535); + } + + for (var id = 1; id <= 10; id += 2) + { + pump.Register(id, MakeBody(50), CancellationToken.None, initialCredits: 16); + } + + Assert.Empty(target.Completed); + + for (var id = 1; id <= 10; id += 2) + { + flow.OnSendWindowUpdate(id, 65535); + } + + flow.OnSendWindowUpdate(0, 65535 * 5); + pump.OnWindowUpdate(0); + + for (var id = 1; id <= 10; id += 2) + { + Assert.Contains(id, target.Completed); + } + } + + [Fact(Timeout = 5000)] + public void OnWindowUpdate_stream_level_with_no_unblock_should_still_inject_one_credit() + { + var target = new FakeTarget(); + var flow = MakeFlow(connWindow: 1024 * 1024); + var pump = MakePump(target, flow); + + flow.InitStreamSendWindow(1); + pump.Register(1, MakeBody(50), CancellationToken.None); + + Assert.Empty(target.Emitted); + + pump.OnWindowUpdate(1); + + Assert.NotEmpty(target.Emitted); + } + + // Re-entrancy guard (Fix 1) integration with flow control + + private sealed class InlineCreditFlowTarget : IBodyDrainTarget + { + private FlowControlledBodyPump? _pump; + public IActorRef PipeToTarget { get; } = ActorRefs.Nobody; + public bool HasPendingDemand => false; + public int PreferredChunkSize => 16 * 1024; + public List<(int StreamId, byte[] Data, bool EndStream)> Emitted { get; } = []; + public List Completed { get; } = []; + public List<(int StreamId, Exception Reason)> Failed { get; } = []; + + public void SetPump(FlowControlledBodyPump pump) => _pump = pump; + + public void EmitDataFrames(int streamId, ReadOnlyMemory data, bool endStream) + { + Emitted.Add((streamId, data.ToArray(), endStream)); + if (!endStream && _pump is not null) + { + _pump.AddCredit(); + } + } + + public void OnDrainComplete(int streamId) => Completed.Add(streamId); + public void OnDrainFailed(int streamId, Exception reason) => Failed.Add((streamId, reason)); + } + + [Fact(Timeout = 5000)] + public void OnWindowUpdate_with_inline_AddCredit_should_drain_streams_iteratively() + { + // 30 window-blocked streams with sync bodies AND inline AddCredit from + // EmitDataFrames. Before Fix 1, this created O(30) recursive DrainReady + // stack depth per OnWindowUpdate. After Fix 1, flat iteration. + // With inline credits, each stream costs 1 net credit (EOF only). The pump + // has 48 accumulated credits from registration, enough for all 30 EOFs. + var target = new InlineCreditFlowTarget(); + var flow = MakeFlow(connWindow: 100 * 1024 * 1024); + var pump = new FlowControlledBodyPump(target, flow, new ConnectionPoolContext(), new CancellationTokenSource()); + target.SetPump(pump); + + for (var id = 1; id <= 60; id += 2) + { + flow.InitStreamSendWindow(id); + flow.OnDataSent(id, 65535); + } + + for (var id = 1; id <= 60; id += 2) + { + pump.Register(id, MakeBody(100), CancellationToken.None, initialCredits: 16); + } + + Assert.Empty(target.Completed); + + for (var id = 1; id <= 60; id += 2) + { + flow.OnSendWindowUpdate(id, 1024 * 1024); + } + + flow.OnSendWindowUpdate(0, 100 * 1024 * 1024); + pump.OnWindowUpdate(0); + + for (var id = 1; id <= 60; id += 2) + { + Assert.Contains(id, target.Completed); + } + } + + // Fix 4: Connection-window early-out + + [Fact(Timeout = 5000)] + public void OnWindowUpdate_connection_level_should_skip_scan_when_connection_window_below_threshold() + { + // When connection send window < chunkSize/2, no stream can be eligible + // (Math.Min(streamWindow, connWindow) < minReadSize for all). The scan + // over _windowBlockedStreams is skipped entirely via early-out. + var target = new FakeTarget(); + var connWindow = 48 * 1024; + var flow = MakeFlow(connWindow: connWindow); + var pump = MakePump(target, flow); + + flow.InitStreamSendWindow(1); + flow.InitStreamSendWindow(3); + flow.OnDataSent(1, 65535); + flow.OnDataSent(3, 65535); + + pump.Register(1, MakeBody(50), CancellationToken.None, initialCredits: 16); + pump.Register(3, MakeBody(50), CancellationToken.None, initialCredits: 16); + + Assert.Empty(target.Completed); + + // Restore per-stream windows. + flow.OnSendWindowUpdate(1, 65535); + flow.OnSendWindowUpdate(3, 65535); + + // Exhaust connection window below minReadSize (chunkSize/2 = 8192) + // using a dummy stream so streams 1 and 3 keep their per-stream window. + flow.InitStreamSendWindow(999); + flow.OnSendWindowUpdate(999, connWindow); + flow.OnDataSent(999, (int)(flow.ConnectionSendWindow - 4 * 1024)); + + // Connection window is ~4 KB < 8 KB threshold. + // Early-out should skip the scan — streams stay blocked. + pump.OnWindowUpdate(0); + + Assert.Empty(target.Completed); + + // Restore connection window above threshold and retry. + flow.OnSendWindowUpdate(0, 1024 * 1024); + pump.OnWindowUpdate(0); + + Assert.Contains(1, target.Completed); + Assert.Contains(3, target.Completed); + } + + [Fact(Timeout = 5000)] + public void OnWindowUpdate_connection_level_should_only_check_stream_window_when_connection_eligible() + { + // When connection window >= minReadSize, the scan checks only per-stream + // windows (not the redundant Math.Min with connectionWindow). Streams + // with exhausted per-stream windows remain blocked. + var target = new FakeTarget(); + var flow = MakeFlow(connWindow: 1024 * 1024); + var pump = MakePump(target, flow); + + // Stream 1: per-stream window exhausted. + // Stream 3: per-stream window available. + flow.InitStreamSendWindow(1); + flow.InitStreamSendWindow(3); + flow.OnDataSent(1, 65535); + flow.OnDataSent(3, 65535); + + pump.Register(1, MakeBody(50), CancellationToken.None, initialCredits: 16); + pump.Register(3, MakeBody(50), CancellationToken.None, initialCredits: 16); + + // Only restore stream 3's window. + flow.OnSendWindowUpdate(3, 65535); + flow.OnSendWindowUpdate(0, 65535); + pump.OnWindowUpdate(0); + + // Stream 3 should drain. Stream 1 stays blocked (per-stream window still 0). + Assert.Contains(3, target.Completed); + Assert.DoesNotContain(1, target.Completed); + + // Restore stream 1's window and retry. + flow.OnSendWindowUpdate(1, 65535); + flow.OnSendWindowUpdate(0, 65535); + pump.OnWindowUpdate(0); + + Assert.Contains(1, target.Completed); + } + + [Fact(Timeout = 10000)] + public void DrainReady_should_be_bounded_with_inline_credits_and_large_body() + { + var target = new InlineCreditFlowTarget(); + var flow = MakeFlow(connWindow: 100 * 1024 * 1024); + var pump = new FlowControlledBodyPump(target, flow, new ConnectionPoolContext(), new CancellationTokenSource()); + target.SetPump(pump); + + flow.InitStreamSendWindow(1); + flow.OnSendWindowUpdate(1, 100 * 1024 * 1024); + + var sw = System.Diagnostics.Stopwatch.StartNew(); + pump.Register(1, MakeBody(1024 * 1024), CancellationToken.None, initialCredits: 16); + sw.Stop(); + + var emittedBytes = target.Emitted.Where(e => !e.EndStream).Sum(e => e.Data.Length); + + // With _yieldBetweenDrainPasses: inline AddCredit is dropped during drain, + // so only initialCredits (16) reads happen. 16 × 16KB = 256KB of 1MB. + Assert.True(emittedBytes <= 16 * 16 * 1024, + $"Expected at most {16 * 16 * 1024} bytes from initial credits, got {emittedBytes}. " + + "Credits are leaking into the drain loop."); + Assert.Empty(target.Completed); + + // Verify HandleContinueDrain advances the drain in bounded steps + var prevBytes = emittedBytes; + pump.HandleContinueDrain(); + var afterFirst = target.Emitted.Where(e => !e.EndStream).Sum(e => e.Data.Length); + Assert.True(afterFirst > prevBytes, "ContinueDrain should advance the drain"); + Assert.True(afterFirst <= prevBytes + 48 * 16 * 1024, + $"ContinueDrain should be bounded to budget reads, got {afterFirst - prevBytes} bytes"); + + // Drive to completion + while (target.Completed.Count == 0) + { + pump.HandleContinueDrain(); + } + + var totalBytes = target.Emitted.Where(e => !e.EndStream).Sum(e => e.Data.Length); + Assert.Equal(1024 * 1024, totalBytes); + Assert.True(sw.ElapsedMilliseconds < 100, + $"Register took {sw.ElapsedMilliseconds}ms — should be < 100ms for bounded drain"); + } + + [Fact(Timeout = 30000)] + public void DrainReady_should_be_bounded_with_512_concurrent_streams() + { + var target = new InlineCreditFlowTarget(); + var flow = MakeFlow(connWindow: 1024 * 1024 * 1024); + var pump = new FlowControlledBodyPump(target, flow, new ConnectionPoolContext(), new CancellationTokenSource()); + target.SetPump(pump); + + var sw = System.Diagnostics.Stopwatch.StartNew(); + for (var id = 1; id <= 1024; id += 2) + { + flow.InitStreamSendWindow(id); + flow.OnSendWindowUpdate(id, 100 * 1024 * 1024); + pump.Register(id, MakeBody(1024 * 1024), CancellationToken.None, initialCredits: 16); + } + + var registerTime = sw.ElapsedMilliseconds; + + // Registration of 512 × 1MB streams with inline credits should be fast + // (bounded by initialCredits per stream, not by total body size). + Assert.True(registerTime < 5000, + $"Registering 512 streams took {registerTime}ms — CPU spinning detected"); + + // Drive all streams to completion via continuation + var iterations = 0; + while (target.Completed.Count < 512 && iterations < 100_000) + { + pump.HandleContinueDrain(); + iterations++; + } + + sw.Stop(); + Assert.Equal(512, target.Completed.Count); + Assert.True(sw.ElapsedMilliseconds < 20_000, + $"Full drain took {sw.ElapsedMilliseconds}ms — expected < 20s"); + } } diff --git a/src/GaudiHTTP.Tests/Protocol/Body/QueuedBodyStreamSpec.cs b/src/GaudiHTTP.Tests/Protocol/Body/QueuedBodyStreamSpec.cs index 0927154a6..c6dfb4afb 100644 --- a/src/GaudiHTTP.Tests/Protocol/Body/QueuedBodyStreamSpec.cs +++ b/src/GaudiHTTP.Tests/Protocol/Body/QueuedBodyStreamSpec.cs @@ -81,6 +81,47 @@ public async Task CopyToAsync_should_write_nothing_for_an_empty_body() Assert.Empty(destination.ToArray()); } + [Fact(Timeout = 5000)] + public void Dispose_without_reading_should_invoke_onAbandoned_callback() + { + var callbackFired = false; + var reader = new QueuedBodyReader(capacity: 4); + reader.TryEnqueue(new byte[] { 1, 2, 3 }); + reader.Complete(); + + var stream = new QueuedBodyStream(reader, onAbandoned: () => callbackFired = true); + stream.Dispose(); + + Assert.True(callbackFired); + } + + [Fact(Timeout = 5000)] + public async Task Dispose_after_fully_reading_should_not_invoke_callback() + { + var callbackFired = false; + var reader = new QueuedBodyReader(capacity: 4); + reader.TryEnqueue(new byte[] { 1, 2, 3 }); + reader.Complete(); + + var stream = new QueuedBodyStream(reader, onAbandoned: () => callbackFired = true); + var buf = new byte[64]; + while (await stream.ReadAsync(buf, TestContext.Current.CancellationToken) > 0) { } + stream.Dispose(); + + Assert.False(callbackFired); + } + + [Fact(Timeout = 5000)] + public void Dispose_without_callback_should_not_throw() + { + var reader = new QueuedBodyReader(capacity: 4); + reader.TryEnqueue(new byte[] { 1, 2, 3 }); + reader.Complete(); + + var stream = new QueuedBodyStream(reader); + stream.Dispose(); + } + private sealed class TrackingArrayPool : ArrayPool { private readonly ArrayPool _inner = Shared; diff --git a/src/GaudiHTTP.Tests/Protocol/Semantics/Redirect/RedirectCoreSpec.cs b/src/GaudiHTTP.Tests/Protocol/Semantics/Redirect/RedirectCoreSpec.cs index 3b4e0d2c0..ae5971ecc 100644 --- a/src/GaudiHTTP.Tests/Protocol/Semantics/Redirect/RedirectCoreSpec.cs +++ b/src/GaudiHTTP.Tests/Protocol/Semantics/Redirect/RedirectCoreSpec.cs @@ -163,7 +163,7 @@ public async Task RequestDirection_should_forward_multiple_requests_in_order() Assert.Same(req2, results[1]); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9110-15.4")] public void RedirectCore_should_forward_final_response_when_200_ok() { @@ -181,7 +181,7 @@ public void RedirectCore_should_forward_final_response_when_200_ok() Assert.Same(response, respOut.ExpectNext(TestContext.Current.CancellationToken)); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9110-15.4")] public void RedirectCore_should_forward_final_response_when_404() { @@ -197,7 +197,7 @@ public void RedirectCore_should_forward_final_response_when_404() Assert.Same(response, respOut.ExpectNext(TestContext.Current.CancellationToken)); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9110-15.4")] public void RedirectCore_should_forward_final_response_when_request_message_is_null() { @@ -215,7 +215,7 @@ public void RedirectCore_should_forward_final_response_when_request_message_is_n Assert.Same(response, respOut.ExpectNext(TestContext.Current.CancellationToken)); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9110-15.4")] public void RedirectCore_should_emit_redirect_on_out1_when_301() { @@ -238,7 +238,7 @@ public void RedirectCore_should_emit_redirect_on_out1_when_301() respOut.ExpectNoMsg(TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9110-15.4")] public void RedirectCore_should_emit_redirect_on_out1_when_302() { @@ -256,7 +256,7 @@ public void RedirectCore_should_emit_redirect_on_out1_when_302() respOut.ExpectNoMsg(TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9110-15.4")] public void RedirectCore_should_rewrite_method_to_get_when_303() { @@ -275,7 +275,7 @@ public void RedirectCore_should_rewrite_method_to_get_when_303() respOut.ExpectNoMsg(TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9110-15.4")] public void RedirectCore_should_preserve_method_when_307() { @@ -293,7 +293,7 @@ public void RedirectCore_should_preserve_method_when_307() respOut.ExpectNoMsg(TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9110-15.4")] public void RedirectCore_should_preserve_method_when_308() { @@ -312,7 +312,7 @@ public void RedirectCore_should_preserve_method_when_308() respOut.ExpectNoMsg(TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9110-15.4")] public void RedirectCore_should_carry_redirect_handler_in_options() { diff --git a/src/GaudiHTTP.Tests/Protocol/Semantics/Redirect/UriRedirectSpec.cs b/src/GaudiHTTP.Tests/Protocol/Semantics/Redirect/UriRedirectSpec.cs index 13c2126b6..237e30647 100644 --- a/src/GaudiHTTP.Tests/Protocol/Semantics/Redirect/UriRedirectSpec.cs +++ b/src/GaudiHTTP.Tests/Protocol/Semantics/Redirect/UriRedirectSpec.cs @@ -7,12 +7,13 @@ namespace GaudiHTTP.Tests.Protocol.Semantics.Redirect; public sealed class UriRedirectSpec { - private static readonly Http11ClientEncoder Encoder = new(ClientOptionDefaults.Http11Encoder()); + private static Http11ClientEncoder MakeEncoder() => new(ClientOptionDefaults.Http11Encoder()); private static string EncodeHttp11(HttpRequestMessage request, int bufferSize = 16384) { + var encoder = MakeEncoder(); var buffer = new byte[bufferSize]; - var written = Encoder.Encode(buffer, request, out _, out _); + var written = encoder.Encode(buffer, request, out _, out _); return System.Text.Encoding.ASCII.GetString(buffer, 0, written); } @@ -44,7 +45,7 @@ public void Http11Encoder_should_encode_extremely_long_uri_when_uri_exceeds_stan var request = new HttpRequestMessage(HttpMethod.Get, longUri); const int bufferSize = 32768; - var written = Encoder.Encode(new byte[bufferSize], request, out _, out _); + var written = MakeEncoder().Encode(new byte[bufferSize], request, out _, out _); Assert.True(written > 0); Assert.True(written < bufferSize); @@ -59,7 +60,7 @@ public void Http11Encoder_should_encode_long_query_string_when_query_parameters_ var request = new HttpRequestMessage(HttpMethod.Get, uri); const int bufferSize = 32768; - var written = Encoder.Encode(new byte[bufferSize], request, out _, out _); + var written = MakeEncoder().Encode(new byte[bufferSize], request, out _, out _); Assert.True(written > 0); Assert.True(written < bufferSize); diff --git a/src/GaudiHTTP.Tests/Protocol/Semantics/Retry/RetryCoreSpec.cs b/src/GaudiHTTP.Tests/Protocol/Semantics/Retry/RetryCoreSpec.cs index c17ac37e3..09121becf 100644 --- a/src/GaudiHTTP.Tests/Protocol/Semantics/Retry/RetryCoreSpec.cs +++ b/src/GaudiHTTP.Tests/Protocol/Semantics/Retry/RetryCoreSpec.cs @@ -170,7 +170,7 @@ public async Task RequestDirection_should_forward_multiple_requests_in_order() Assert.Same(req2, results[1]); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9110-9.2")] public void RetryCore_should_forward_final_response_when_200_ok() { @@ -188,7 +188,7 @@ public void RetryCore_should_forward_final_response_when_200_ok() Assert.Same(response, respOut.ExpectNext(TestContext.Current.CancellationToken)); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9110-9.2")] public void RetryCore_should_forward_final_response_when_404() { @@ -204,7 +204,7 @@ public void RetryCore_should_forward_final_response_when_404() Assert.Same(response, respOut.ExpectNext(TestContext.Current.CancellationToken)); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9110-9.2")] public void RetryCore_should_forward_final_response_when_post_returns_408() { @@ -220,7 +220,7 @@ public void RetryCore_should_forward_final_response_when_post_returns_408() Assert.Same(response, respOut.ExpectNext(TestContext.Current.CancellationToken)); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9110-9.2")] public void RetryCore_should_forward_final_response_when_request_message_is_null() { @@ -236,7 +236,7 @@ public void RetryCore_should_forward_final_response_when_request_message_is_null Assert.Same(response, respOut.ExpectNext(TestContext.Current.CancellationToken)); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9110-9.2")] public void RetryCore_should_emit_retry_on_out1_when_get_returns_408() { @@ -259,7 +259,7 @@ public void RetryCore_should_emit_retry_on_out1_when_get_returns_408() respOut.ExpectNoMsg(TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9110-9.2")] public void RetryCore_should_emit_retry_on_out1_when_get_returns_503() { @@ -277,7 +277,7 @@ public void RetryCore_should_emit_retry_on_out1_when_get_returns_503() respOut.ExpectNoMsg(TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9110-9.2")] public void RetryCore_should_increment_attempt_count_when_retrying() { @@ -295,7 +295,7 @@ public void RetryCore_should_increment_attempt_count_when_retrying() Assert.Equal(2, count); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9110-9.2")] public void RetryCore_should_forward_final_response_when_retry_limit_reached() { @@ -337,7 +337,7 @@ public void RetryCore_should_retry_on_408_when_method_is_idempotent(string metho respOut.ExpectNoMsg(TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9110-9.2")] public void RetryCore_should_forward_on_out2_when_patch_returns_503() { diff --git a/src/GaudiHTTP.Tests/Protocol/Semantics/Retry/RetryTimerSpec.cs b/src/GaudiHTTP.Tests/Protocol/Semantics/Retry/RetryTimerSpec.cs index 80b3d259a..54e0c5508 100644 --- a/src/GaudiHTTP.Tests/Protocol/Semantics/Retry/RetryTimerSpec.cs +++ b/src/GaudiHTTP.Tests/Protocol/Semantics/Retry/RetryTimerSpec.cs @@ -63,7 +63,7 @@ private static HttpResponseMessage BuildResponse( return response; } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9110-9.2")] public void RetryTimer_should_retry_immediately_when_retry_after_is_zero() { @@ -81,7 +81,7 @@ public void RetryTimer_should_retry_immediately_when_retry_after_is_zero() respOut.ExpectNoMsg(TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9110-9.2")] public void RetryTimer_should_delay_retry_when_retry_after_is_positive() { @@ -103,7 +103,7 @@ public void RetryTimer_should_delay_retry_when_retry_after_is_positive() respOut.ExpectNoMsg(TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9110-9.2")] public void RetryTimer_should_pass_final_through_when_retry_timer_is_pending() { @@ -127,7 +127,7 @@ public void RetryTimer_should_pass_final_through_when_retry_timer_is_pending() Assert.Same(responseB, respOut.ExpectNext(TestContext.Current.CancellationToken)); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9110-9.2")] public void RetryTimer_should_prioritize_retry_over_new_request() { @@ -175,7 +175,7 @@ public void RetryTimer_should_prioritize_retry_over_new_request() Assert.Same(request, retryReq); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9110-9.2")] public void RetryTimer_should_have_independent_retry_budgets() { @@ -208,7 +208,7 @@ public void RetryTimer_should_have_independent_retry_budgets() Assert.Same(responseB2, respOut.ExpectNext(TestContext.Current.CancellationToken)); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9110-9.2")] public void RetryTimer_should_absorb_request_upstream_failure() { @@ -241,7 +241,7 @@ public void RetryTimer_should_absorb_request_upstream_failure() responseOutProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken); } - [Fact] + [Fact(Timeout = 5000)] [Trait("RFC", "RFC9110-9.2")] public void RetryTimer_should_absorb_response_upstream_failure() { diff --git a/src/GaudiHTTP.Tests/Protocol/Syntax/Http10/Stages/Http10DecompressionPipelineSpec.cs b/src/GaudiHTTP.Tests/Protocol/Syntax/Http10/Stages/Http10DecompressionPipelineSpec.cs index 14efcdd07..56d0b0b71 100644 --- a/src/GaudiHTTP.Tests/Protocol/Syntax/Http10/Stages/Http10DecompressionPipelineSpec.cs +++ b/src/GaudiHTTP.Tests/Protocol/Syntax/Http10/Stages/Http10DecompressionPipelineSpec.cs @@ -13,13 +13,13 @@ namespace GaudiHTTP.Tests.Protocol.Syntax.Http10.Stages; public sealed class Http10DecompressionPipelineSpec : EngineTestBase { - private static readonly Http10ClientEngine Engine = new(new GaudiClientOptions()); + private static Http10ClientEngine MakeEngine() => new(new GaudiClientOptions()); private static BidiFlow CreateDecompressingEngine() { var decomp = BidiFlow.FromGraph(new ContentEncodingBidiStage()); - return decomp.Atop(Engine.CreateFlow()); + return decomp.Atop(MakeEngine().CreateFlow()); } private static byte[] GzipCompress(byte[] data) diff --git a/src/GaudiHTTP.Tests/Protocol/Syntax/Http11/Client/Http11ClientBodyBackpressureSpec.cs b/src/GaudiHTTP.Tests/Protocol/Syntax/Http11/Client/Http11ClientBodyBackpressureSpec.cs index dfe7bdc60..c6c266624 100644 --- a/src/GaudiHTTP.Tests/Protocol/Syntax/Http11/Client/Http11ClientBodyBackpressureSpec.cs +++ b/src/GaudiHTTP.Tests/Protocol/Syntax/Http11/Client/Http11ClientBodyBackpressureSpec.cs @@ -40,7 +40,13 @@ public override ValueTask ReadAsync(Memory buffer, CancellationToken public override long Position { get => _position; set => throw new NotSupportedException(); } public override void Flush() { } public override int Read(byte[] buffer, int offset, int count) - => ReadAsync(buffer.AsMemory(offset, count)).Result; + { + ReadsIssued++; + var n = Math.Min(count, length - _position); + buffer.AsSpan(offset, n).Fill(0x42); + _position += n; + return n; + } public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); public override void SetLength(long value) => throw new NotSupportedException(); public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); diff --git a/src/GaudiHTTP.Tests/Protocol/Syntax/Http11/RoundTrip/Http11RoundTripBodySpec.cs b/src/GaudiHTTP.Tests/Protocol/Syntax/Http11/RoundTrip/Http11RoundTripBodySpec.cs index c98dd8757..a2ff81425 100644 --- a/src/GaudiHTTP.Tests/Protocol/Syntax/Http11/RoundTrip/Http11RoundTripBodySpec.cs +++ b/src/GaudiHTTP.Tests/Protocol/Syntax/Http11/RoundTrip/Http11RoundTripBodySpec.cs @@ -8,11 +8,12 @@ namespace GaudiHTTP.Tests.Protocol.Syntax.Http11.RoundTrip; public sealed class Http11RoundTripBodySpec { - private static readonly Http11ClientEncoder Encoder = new(ClientOptionDefaults.Http11Encoder()); + private static Http11ClientEncoder MakeEncoder() => new(ClientOptionDefaults.Http11Encoder()); private static int EncodeRequest(HttpRequestMessage request, Span buffer) { - return Encoder.Encode(buffer, request, out _, out _); + var encoder = MakeEncoder(); + return encoder.Encode(buffer, request, out _, out _); } private static ReadOnlyMemory BuildResponse(int status, string reason, string body, diff --git a/src/GaudiHTTP.Tests/Protocol/Syntax/Http11/RoundTrip/Http11RoundTripMethodSpec.cs b/src/GaudiHTTP.Tests/Protocol/Syntax/Http11/RoundTrip/Http11RoundTripMethodSpec.cs index 18bf9ce6b..b47279608 100644 --- a/src/GaudiHTTP.Tests/Protocol/Syntax/Http11/RoundTrip/Http11RoundTripMethodSpec.cs +++ b/src/GaudiHTTP.Tests/Protocol/Syntax/Http11/RoundTrip/Http11RoundTripMethodSpec.cs @@ -9,11 +9,12 @@ namespace GaudiHTTP.Tests.Protocol.Syntax.Http11.RoundTrip; public sealed class Http11RoundTripMethodSpec { - private static readonly Http11ClientEncoder Encoder = new(ClientOptionDefaults.Http11Encoder()); + private static Http11ClientEncoder MakeEncoder() => new(ClientOptionDefaults.Http11Encoder()); private static int EncodeRequest(HttpRequestMessage request, Span buffer) { - return Encoder.Encode(buffer, request, out _, out _); + var encoder = MakeEncoder(); + return encoder.Encode(buffer, request, out _, out _); } private static ReadOnlyMemory BuildResponse(int status, string reason, string body, diff --git a/src/GaudiHTTP.Tests/Protocol/Syntax/Http2/FlowControllerAdaptiveScalingSpec.cs b/src/GaudiHTTP.Tests/Protocol/Syntax/Http2/Client/FlowControl/FlowControllerAdaptiveScalingSpec.cs similarity index 97% rename from src/GaudiHTTP.Tests/Protocol/Syntax/Http2/FlowControllerAdaptiveScalingSpec.cs rename to src/GaudiHTTP.Tests/Protocol/Syntax/Http2/Client/FlowControl/FlowControllerAdaptiveScalingSpec.cs index 73c86149d..cb2fc8e8d 100644 --- a/src/GaudiHTTP.Tests/Protocol/Syntax/Http2/FlowControllerAdaptiveScalingSpec.cs +++ b/src/GaudiHTTP.Tests/Protocol/Syntax/Http2/Client/FlowControl/FlowControllerAdaptiveScalingSpec.cs @@ -1,7 +1,7 @@ using Microsoft.Extensions.Time.Testing; using GaudiHTTP.Protocol.Syntax.Http2; -namespace GaudiHTTP.Tests.Protocol.Syntax.Http2; +namespace GaudiHTTP.Tests.Protocol.Syntax.Http2.Client.FlowControl; public sealed class FlowControllerAdaptiveScalingSpec { diff --git a/src/GaudiHTTP.Tests/Protocol/Syntax/Http2/FlowControllerReservationSpec.cs b/src/GaudiHTTP.Tests/Protocol/Syntax/Http2/Client/FlowControl/FlowControllerReservationSpec.cs similarity index 96% rename from src/GaudiHTTP.Tests/Protocol/Syntax/Http2/FlowControllerReservationSpec.cs rename to src/GaudiHTTP.Tests/Protocol/Syntax/Http2/Client/FlowControl/FlowControllerReservationSpec.cs index 1da215b84..5d03fd93b 100644 --- a/src/GaudiHTTP.Tests/Protocol/Syntax/Http2/FlowControllerReservationSpec.cs +++ b/src/GaudiHTTP.Tests/Protocol/Syntax/Http2/Client/FlowControl/FlowControllerReservationSpec.cs @@ -1,6 +1,6 @@ using GaudiHTTP.Protocol.Syntax.Http2; -namespace GaudiHTTP.Tests.Protocol.Syntax.Http2; +namespace GaudiHTTP.Tests.Protocol.Syntax.Http2.Client.FlowControl; public sealed class FlowControllerReservationSpec { diff --git a/src/GaudiHTTP.Tests/Protocol/Syntax/Http2/Http2AdaptiveWindowScalingRegressionSpec.cs b/src/GaudiHTTP.Tests/Protocol/Syntax/Http2/Client/FlowControl/Http2AdaptiveWindowScalingRegressionSpec.cs similarity index 99% rename from src/GaudiHTTP.Tests/Protocol/Syntax/Http2/Http2AdaptiveWindowScalingRegressionSpec.cs rename to src/GaudiHTTP.Tests/Protocol/Syntax/Http2/Client/FlowControl/Http2AdaptiveWindowScalingRegressionSpec.cs index 7b59f8209..4b2228f13 100644 --- a/src/GaudiHTTP.Tests/Protocol/Syntax/Http2/Http2AdaptiveWindowScalingRegressionSpec.cs +++ b/src/GaudiHTTP.Tests/Protocol/Syntax/Http2/Client/FlowControl/Http2AdaptiveWindowScalingRegressionSpec.cs @@ -1,7 +1,7 @@ using Microsoft.Extensions.Time.Testing; using GaudiHTTP.Protocol.Syntax.Http2; -namespace GaudiHTTP.Tests.Protocol.Syntax.Http2; +namespace GaudiHTTP.Tests.Protocol.Syntax.Http2.Client.FlowControl; /// /// Regression guard for HTTP/2 adaptive receive-window scaling (feature/h2-adaptive-window-scaling). diff --git a/src/GaudiHTTP.Tests/Protocol/Syntax/Http2/RttEstimatorSpec.cs b/src/GaudiHTTP.Tests/Protocol/Syntax/Http2/Client/FlowControl/RttEstimatorSpec.cs similarity index 97% rename from src/GaudiHTTP.Tests/Protocol/Syntax/Http2/RttEstimatorSpec.cs rename to src/GaudiHTTP.Tests/Protocol/Syntax/Http2/Client/FlowControl/RttEstimatorSpec.cs index 1623819c8..36d31d1a3 100644 --- a/src/GaudiHTTP.Tests/Protocol/Syntax/Http2/RttEstimatorSpec.cs +++ b/src/GaudiHTTP.Tests/Protocol/Syntax/Http2/Client/FlowControl/RttEstimatorSpec.cs @@ -1,7 +1,7 @@ using Microsoft.Extensions.Time.Testing; using GaudiHTTP.Protocol.Syntax.Http2; -namespace GaudiHTTP.Tests.Protocol.Syntax.Http2; +namespace GaudiHTTP.Tests.Protocol.Syntax.Http2.Client.FlowControl; public sealed class RttEstimatorSpec { diff --git a/src/GaudiHTTP.Tests/Protocol/Syntax/Http2/WindowScalerSpec.cs b/src/GaudiHTTP.Tests/Protocol/Syntax/Http2/Client/FlowControl/WindowScalerSpec.cs similarity index 97% rename from src/GaudiHTTP.Tests/Protocol/Syntax/Http2/WindowScalerSpec.cs rename to src/GaudiHTTP.Tests/Protocol/Syntax/Http2/Client/FlowControl/WindowScalerSpec.cs index 164ea5bf2..43f5f6cbe 100644 --- a/src/GaudiHTTP.Tests/Protocol/Syntax/Http2/WindowScalerSpec.cs +++ b/src/GaudiHTTP.Tests/Protocol/Syntax/Http2/Client/FlowControl/WindowScalerSpec.cs @@ -1,6 +1,6 @@ using GaudiHTTP.Protocol.Syntax.Http2; -namespace GaudiHTTP.Tests.Protocol.Syntax.Http2; +namespace GaudiHTTP.Tests.Protocol.Syntax.Http2.Client.FlowControl; public sealed class WindowScalerSpec { diff --git a/src/GaudiHTTP.Tests/Protocol/Syntax/Http2/Frames/Http2StreamStateMachineSpec.cs b/src/GaudiHTTP.Tests/Protocol/Syntax/Http2/Frames/Http2DecoderStreamStateSpec.cs similarity index 99% rename from src/GaudiHTTP.Tests/Protocol/Syntax/Http2/Frames/Http2StreamStateMachineSpec.cs rename to src/GaudiHTTP.Tests/Protocol/Syntax/Http2/Frames/Http2DecoderStreamStateSpec.cs index b334a760b..419658ce0 100644 --- a/src/GaudiHTTP.Tests/Protocol/Syntax/Http2/Frames/Http2StreamStateMachineSpec.cs +++ b/src/GaudiHTTP.Tests/Protocol/Syntax/Http2/Frames/Http2DecoderStreamStateSpec.cs @@ -3,7 +3,7 @@ namespace GaudiHTTP.Tests.Protocol.Syntax.Http2.Frames; -public sealed class Http2StreamStateMachineSpec +public sealed class Http2DecoderStreamStateSpec { private static byte[] MakeHeadersBytes(int streamId, bool endStream = false, string status = "200") { diff --git a/src/GaudiHTTP.Tests/Protocol/Syntax/Http2/Server/Decoder/Http2ServerHpackTableSizeSpec.cs b/src/GaudiHTTP.Tests/Protocol/Syntax/Http2/Server/Decoder/Http2ServerHpackTableSizeSpec.cs index 4640900c7..47517f77c 100644 --- a/src/GaudiHTTP.Tests/Protocol/Syntax/Http2/Server/Decoder/Http2ServerHpackTableSizeSpec.cs +++ b/src/GaudiHTTP.Tests/Protocol/Syntax/Http2/Server/Decoder/Http2ServerHpackTableSizeSpec.cs @@ -23,13 +23,12 @@ public sealed class Http2ServerHpackTableSizeSpec MaxHeaderCount = 100, }; - private static readonly HpackEncoder Encoder = new(useHuffman: false); - private static byte[] EncodeRequest() { + var encoder = new HpackEncoder(useHuffman: false); using var owner = MemoryPool.Shared.Rent(4096); var span = owner.Memory.Span; - var written = Encoder.Encode(new List + var written = encoder.Encode(new List { new(":method", "GET"), new(":path", "/"), diff --git a/src/GaudiHTTP.Tests/Streams/ProtocolRouterSpec.cs b/src/GaudiHTTP.Tests/Streams/ProtocolRouterSpec.cs index 40af2ef4a..d2d6cd69a 100644 --- a/src/GaudiHTTP.Tests/Streams/ProtocolRouterSpec.cs +++ b/src/GaudiHTTP.Tests/Streams/ProtocolRouterSpec.cs @@ -5,12 +5,12 @@ namespace GaudiHTTP.Tests.Streams; public sealed class ProtocolRouterSpec { - private static readonly GaudiServerOptions DefaultOptions = new(); + private static GaudiServerOptions MakeOptions() => new(); [Fact(Timeout = 5000)] public void ResolveEngine_should_return_http10_for_http10_version() { - var engine = ProtocolRouter.ResolveEngine(new Version(1, 0), DefaultOptions); + var engine = ProtocolRouter.ResolveEngine(new Version(1, 0), MakeOptions()); Assert.NotNull(engine); Assert.IsType(engine); @@ -19,7 +19,7 @@ public void ResolveEngine_should_return_http10_for_http10_version() [Fact(Timeout = 5000)] public void ResolveEngine_should_return_http11_for_http11_version() { - var engine = ProtocolRouter.ResolveEngine(new Version(1, 1), DefaultOptions); + var engine = ProtocolRouter.ResolveEngine(new Version(1, 1), MakeOptions()); Assert.NotNull(engine); Assert.IsType(engine); @@ -28,7 +28,7 @@ public void ResolveEngine_should_return_http11_for_http11_version() [Fact(Timeout = 5000)] public void ResolveEngine_should_return_http20_for_http20_version() { - var engine = ProtocolRouter.ResolveEngine(new Version(2, 0), DefaultOptions); + var engine = ProtocolRouter.ResolveEngine(new Version(2, 0), MakeOptions()); Assert.NotNull(engine); Assert.IsType(engine); @@ -37,7 +37,7 @@ public void ResolveEngine_should_return_http20_for_http20_version() [Fact(Timeout = 5000)] public void ResolveEngine_should_return_http30_for_http30_version() { - var engine = ProtocolRouter.ResolveEngine(new Version(3, 0), DefaultOptions); + var engine = ProtocolRouter.ResolveEngine(new Version(3, 0), MakeOptions()); Assert.NotNull(engine); Assert.IsType(engine); @@ -46,7 +46,7 @@ public void ResolveEngine_should_return_http30_for_http30_version() [Fact(Timeout = 5000)] public void ResolveEngine_should_return_http11_for_unknown_version() { - var engine = ProtocolRouter.ResolveEngine(new Version(4, 0), DefaultOptions); + var engine = ProtocolRouter.ResolveEngine(new Version(4, 0), MakeOptions()); Assert.NotNull(engine); Assert.IsType(engine); @@ -55,7 +55,7 @@ public void ResolveEngine_should_return_http11_for_unknown_version() [Fact(Timeout = 5000)] public void ResolveNegotiating_should_return_negotiating_engine() { - var engine = ProtocolRouter.ResolveNegotiating(DefaultOptions); + var engine = ProtocolRouter.ResolveNegotiating(MakeOptions()); Assert.NotNull(engine); Assert.IsType(engine); diff --git a/src/GaudiHTTP/Protocol/Body/BodyPumpBase.cs b/src/GaudiHTTP/Protocol/Body/BodyPumpBase.cs index 8dc2d61d3..740fa32cc 100644 --- a/src/GaudiHTTP/Protocol/Body/BodyPumpBase.cs +++ b/src/GaudiHTTP/Protocol/Body/BodyPumpBase.cs @@ -23,6 +23,9 @@ internal abstract class BodyPumpBase where TStreamId : notnull private int _budget; private double _ema; private long _lastPullTicks; + private bool _isDraining; + private bool _continuationPending; + protected bool _yieldBetweenDrainPasses; protected BodyPumpBase( IBodyDrainTarget target, @@ -39,7 +42,26 @@ protected BodyPumpBase( public void AddCredit() { - UpdateEma(); + if (_yieldBetweenDrainPasses && (_isDraining || _continuationPending)) + { + return; + } + + AddCreditCore(updateEma: true); + } + + internal void AddCreditWithoutEma() + { + AddCreditCore(updateEma: false); + } + + private void AddCreditCore(bool updateEma) + { + if (updateEma && !_isDraining) + { + UpdateEma(); + } + _credits = Math.Min(_credits + 1, MaxBudget); var threshold = Math.Max(Math.Min(_budget / 2, _activeSlots.Count), 1); @@ -70,7 +92,7 @@ public void Register(TStreamId streamId, Stream bodyStream, CancellationToken re for (var i = 0; i < initialCredits; i++) { - AddCredit(); + AddCreditWithoutEma(); } } @@ -161,14 +183,24 @@ public void CancelAll() _readyQueue.Clear(); _cancelledStreams.Clear(); _credits = 0; + _continuationPending = false; OnCancelAll(); } + public void HandleContinueDrain() + { + _continuationPending = false; + _credits = Math.Min(_credits + _budget, MaxBudget); + DrainReady(_budget); + } + // H2 flow-control extension points — only FlowControlledBodyPump overrides these protected virtual void OnCancelAll() { } protected virtual void OnStreamCancelled(TStreamId streamId) { } + protected virtual void ScheduleContinuation() { } + // When returning false, the override is responsible for tracking the stream // (e.g., moving it to a blocked set). The stream is removed from the ready queue. protected virtual bool IsStreamEligible(TStreamId streamId, BodyDrainSlot slot) => true; @@ -185,38 +217,65 @@ protected virtual void EnqueueStream(TStreamId streamId) private void DrainReady(int maxReads) { - var reads = 0; - var queueSize = _readyQueue.Count; - while (reads < maxReads && _credits > 0 && queueSize-- > 0) + if (_isDraining) { - if (!_readyQueue.TryDequeue(out var streamId)) - { - break; - } - - if (_cancelledStreams.Remove(streamId)) - { - continue; - } - - if (!_activeSlots.TryGetValue(streamId, out var slot)) - { - continue; - } + return; + } - if (slot.IsReadInFlight) + _isDraining = true; + try + { + while (true) { - _readyQueue.Enqueue(streamId); - continue; + var reads = 0; + var queueSize = _readyQueue.Count; + while (reads < maxReads && _credits > 0 && queueSize-- > 0) + { + if (!_readyQueue.TryDequeue(out var streamId)) + { + break; + } + + if (_cancelledStreams.Remove(streamId)) + { + continue; + } + + if (!_activeSlots.TryGetValue(streamId, out var slot)) + { + continue; + } + + if (slot.IsReadInFlight) + { + _readyQueue.Enqueue(streamId); + continue; + } + + if (!IsStreamEligible(streamId, slot)) + { + continue; + } + + PerformRead(streamId, slot); + reads++; + } + + if (_credits <= 0 || _readyQueue.Count == 0) + { + break; + } } - if (!IsStreamEligible(streamId, slot)) + if (_readyQueue.Count > 0 && !_continuationPending) { - continue; + _continuationPending = true; + ScheduleContinuation(); } - - PerformRead(streamId, slot); - reads++; + } + finally + { + _isDraining = false; } } diff --git a/src/GaudiHTTP/Protocol/Body/DrainMessages.cs b/src/GaudiHTTP/Protocol/Body/DrainMessages.cs index 17657bdb7..f14dd05e9 100644 --- a/src/GaudiHTTP/Protocol/Body/DrainMessages.cs +++ b/src/GaudiHTTP/Protocol/Body/DrainMessages.cs @@ -2,3 +2,9 @@ namespace GaudiHTTP.Protocol.Body; internal readonly record struct DrainReadComplete(TStreamId StreamId, int BytesRead); internal readonly record struct DrainReadFailed(TStreamId StreamId, Exception Reason); + +internal sealed class ContinueDrain +{ + public static readonly ContinueDrain Instance = new(); + private ContinueDrain() { } +} diff --git a/src/GaudiHTTP/Protocol/Body/FlowControlledBodyPump.cs b/src/GaudiHTTP/Protocol/Body/FlowControlledBodyPump.cs index 59b1aaad0..9cd3b03c9 100644 --- a/src/GaudiHTTP/Protocol/Body/FlowControlledBodyPump.cs +++ b/src/GaudiHTTP/Protocol/Body/FlowControlledBodyPump.cs @@ -1,3 +1,4 @@ +using Akka.Actor; using GaudiHTTP.Pooling; using GaudiHTTP.Protocol.Syntax.Http2; @@ -17,49 +18,48 @@ public FlowControlledBodyPump( : base(target, poolContext, connectionCts) { _flowController = flowController; + _yieldBetweenDrainPasses = true; } public void OnWindowUpdate(int streamId) { + var unblocked = 0; + if (streamId == 0) { - // Connection-level update: re-evaluate all blocked streams and unblock eligible ones. - _unblockedTemp.Clear(); - foreach (var blocked in _windowBlockedStreams) + var minRead = ComputeMinReadSize(); + if (_flowController.ConnectionSendWindow >= minRead) { - var window = Math.Min( - _flowController.GetStreamSendWindow(blocked), - _flowController.ConnectionSendWindow); - if (window >= ComputeMinReadSize()) + _unblockedTemp.Clear(); + foreach (var blocked in _windowBlockedStreams) { - _unblockedTemp.Add(blocked); + if (_flowController.GetStreamSendWindow(blocked) >= minRead) + { + _unblockedTemp.Add(blocked); + } } - } - foreach (var id in _unblockedTemp) - { - _windowBlockedStreams.Remove(id); - EnqueueStream(id); + foreach (var id in _unblockedTemp) + { + _windowBlockedStreams.Remove(id); + EnqueueStream(id); + } + + unblocked = _unblockedTemp.Count; } } else if (_windowBlockedStreams.Remove(streamId)) { EnqueueStream(streamId); + unblocked = 1; } - // Always inject credits when active streams exist. The pump can reach zero credits - // legitimately: the initial burst consumes bootstrap credits, all streams become - // window-blocked, and no further OnOutboundFlushed calls replenish credits because - // no data is being pushed. When a WINDOW_UPDATE subsequently unblocks streams (or - // streams are already in the ready queue from a prior re-enqueue), the pump must be - // able to read them. Without this unconditional boost the pump deadlocks — streams - // sit in the ready queue with available window but zero credits to drive reads, - // eventually tripping the data-rate monitor which RST_STREAMs the connection. if (GetActiveStreamCount() > 0) { - for (var i = 0; i < 16; i++) + var boost = Math.Clamp(unblocked, 1, 16); + for (var i = 0; i < boost; i++) { - AddCredit(); + AddCreditWithoutEma(); } } } @@ -108,6 +108,11 @@ protected override void AfterRead(int streamId, BodyDrainSlot slot, int byt slot.ReservedWindow = 0; } + protected override void ScheduleContinuation() + { + Target.PipeToTarget.Tell(ContinueDrain.Instance, ActorRefs.NoSender); + } + protected override void OnCancelAll() { _windowBlockedStreams.Clear(); diff --git a/src/GaudiHTTP/Protocol/Body/MultiplexedBodyPump.cs b/src/GaudiHTTP/Protocol/Body/MultiplexedBodyPump.cs index 58c691a17..154c2143d 100644 --- a/src/GaudiHTTP/Protocol/Body/MultiplexedBodyPump.cs +++ b/src/GaudiHTTP/Protocol/Body/MultiplexedBodyPump.cs @@ -1,3 +1,4 @@ +using Akka.Actor; using GaudiHTTP.Pooling; namespace GaudiHTTP.Protocol.Body; @@ -10,6 +11,12 @@ public MultiplexedBodyPump( CancellationTokenSource connectionCts) : base(target, poolContext, connectionCts) { + _yieldBetweenDrainPasses = true; + } + + protected override void ScheduleContinuation() + { + Target.PipeToTarget.Tell(ContinueDrain.Instance, ActorRefs.NoSender); } public void Cleanup() => CancelAll(); diff --git a/src/GaudiHTTP/Protocol/Body/QueuedBodyReader.cs b/src/GaudiHTTP/Protocol/Body/QueuedBodyReader.cs index 3bd9bff06..c9376c868 100644 --- a/src/GaudiHTTP/Protocol/Body/QueuedBodyReader.cs +++ b/src/GaudiHTTP/Protocol/Body/QueuedBodyReader.cs @@ -301,7 +301,9 @@ public void Reset() } } - public Stream AsStream() => new QueuedBodyStream(this); + Stream IBodyReader.AsStream() => new QueuedBodyStream(this); + + public Stream AsStream(Action? onAbandoned = null) => new QueuedBodyStream(this, onAbandoned); public void Dispose() => Reset(); diff --git a/src/GaudiHTTP/Protocol/Body/QueuedBodyStream.cs b/src/GaudiHTTP/Protocol/Body/QueuedBodyStream.cs index 0b82f4ed4..445e517b3 100644 --- a/src/GaudiHTTP/Protocol/Body/QueuedBodyStream.cs +++ b/src/GaudiHTTP/Protocol/Body/QueuedBodyStream.cs @@ -1,10 +1,11 @@ namespace GaudiHTTP.Protocol.Body; -internal sealed class QueuedBodyStream(QueuedBodyReader reader) : Stream +internal sealed class QueuedBodyStream(QueuedBodyReader reader, Action? onAbandoned = null) : Stream { private ReadOnlyMemory _current; private int _offset; private bool _done; + private bool _disposed; public override bool CanRead => true; public override bool CanSeek => false; @@ -139,6 +140,23 @@ private BodyReadResult ReadNextSegment() return vt.Result; } + protected override void Dispose(bool disposing) + { + if (_disposed) + { + return; + } + + _disposed = true; + + if (disposing && !_done) + { + onAbandoned?.Invoke(); + } + + base.Dispose(disposing); + } + public override void Flush() { } diff --git a/src/GaudiHTTP/Protocol/Syntax/Http2/Client/Http2ClientSessionManager.cs b/src/GaudiHTTP/Protocol/Syntax/Http2/Client/Http2ClientSessionManager.cs index a497e3cb5..ea1e5b251 100644 --- a/src/GaudiHTTP/Protocol/Syntax/Http2/Client/Http2ClientSessionManager.cs +++ b/src/GaudiHTTP/Protocol/Syntax/Http2/Client/Http2ClientSessionManager.cs @@ -16,6 +16,7 @@ namespace GaudiHTTP.Protocol.Syntax.Http2.Client; internal sealed class Http2ClientSessionManager : IBodyDrainTarget { + internal sealed record AbandonedResponseBody(int StreamId); private readonly Http2ClientEncoderOptions _encoderOptions; private readonly Http2ClientDecoderOptions _decoderOptions; private readonly GaudiClientOptions _options; @@ -522,10 +523,6 @@ void IBodyDrainTarget.EmitDataFrames(int streamId, ReadOnlyMemory dat EmitFrame(new DataFrame(streamId, remaining, endStream)); } - if (!endStream && !data.IsEmpty) - { - _pump?.AddCredit(); - } } void IBodyDrainTarget.OnDrainComplete(int streamId) @@ -853,7 +850,10 @@ private void DecodeHeaders(int streamId, bool endStream) var queued = _poolContext.Rent(() => new QueuedBodyReader(capacity: 8)); state.InitBodyReader(queued); - var bodyStream = state.GetBodyStream(); + var stageActor = _ops.StageActor; + var capturedStreamId = streamId; + var bodyStream = queued.AsStream(onAbandoned: () => + stageActor.Tell(new AbandonedResponseBody(capturedStreamId))); streamingResponse.Content = new StreamContent(bodyStream); state.ApplyContentHeadersTo(streamingResponse.Content); @@ -885,6 +885,10 @@ public void OnBodyMessage(object msg) { switch (msg) { + case ContinueDrain: + _pump?.HandleContinueDrain(); + break; + case DrainReadComplete read: _pump?.HandleReadComplete(read.StreamId, read.BytesRead); break; @@ -892,6 +896,40 @@ public void OnBodyMessage(object msg) case DrainReadFailed failed: _pump?.HandleReadFailed(failed.StreamId, failed.Reason); break; + + case AbandonedResponseBody abandoned: + OnResponseBodyAbandoned(abandoned.StreamId); + break; + } + } + + private void OnResponseBodyAbandoned(int streamId) + { + if (!_streams.TryGetValue(streamId, out var state)) + { + return; + } + + Tracing.For("Protocol").Debug(this, + "HTTP/2: response body abandoned (stream={0}) — sending RST_STREAM", streamId); + + state.AbortBody(); + EmitFrame(new RstStreamFrame(streamId, Http2ErrorCode.NoError)); + _streams.Remove(streamId); + ReturnBodyReader(state); + state.Reset(); + _statePool.Return(state); + + if (!_tracker.OnStreamClosed(streamId)) + { + return; + } + + _flow.RemoveStreamSendWindow(streamId); + var signal = _flow.OnStreamClosed(streamId); + if (signal is { } windowUpdate) + { + EmitFrame(new WindowUpdateFrame(windowUpdate.StreamId, windowUpdate.Increment)); } } diff --git a/src/GaudiHTTP/Protocol/Syntax/Http2/Server/Http2ServerSessionManager.cs b/src/GaudiHTTP/Protocol/Syntax/Http2/Server/Http2ServerSessionManager.cs index ff39f55ad..eab04e4d6 100644 --- a/src/GaudiHTTP/Protocol/Syntax/Http2/Server/Http2ServerSessionManager.cs +++ b/src/GaudiHTTP/Protocol/Syntax/Http2/Server/Http2ServerSessionManager.cs @@ -412,6 +412,10 @@ public void OnBodyMessage(object msg) { switch (msg) { + case ContinueDrain: + _pump?.HandleContinueDrain(); + break; + case DrainReadComplete read: _pump?.HandleReadComplete(read.StreamId, read.BytesRead); break; @@ -959,11 +963,6 @@ void IBodyDrainTarget.EmitDataFrames(int streamId, ReadOnlyMemory dat } EmitBufferedDataFrames(streamId, data, endStream: false); - - if (!endStream) - { - _pump?.AddCredit(); - } } void IBodyDrainTarget.OnDrainComplete(int streamId) diff --git a/src/GaudiHTTP/Protocol/Syntax/Http3/Client/Http3ClientSessionManager.cs b/src/GaudiHTTP/Protocol/Syntax/Http3/Client/Http3ClientSessionManager.cs index 5efb2c64d..c3fc4f01f 100644 --- a/src/GaudiHTTP/Protocol/Syntax/Http3/Client/Http3ClientSessionManager.cs +++ b/src/GaudiHTTP/Protocol/Syntax/Http3/Client/Http3ClientSessionManager.cs @@ -15,6 +15,7 @@ namespace GaudiHTTP.Protocol.Syntax.Http3.Client; internal sealed class Http3ClientSessionManager : IBodyDrainTarget { + internal sealed record AbandonedResponseBody(long StreamId); private readonly Http3ClientEncoderOptions _encoderOptions; private readonly Http3ClientDecoderOptions _decoderOptions; private readonly GaudiClientOptions _options; @@ -168,6 +169,10 @@ public void OnBodyMessage(object msg) { switch (msg) { + case ContinueDrain: + _pump?.HandleContinueDrain(); + break; + case DrainReadComplete read: _pump?.HandleReadComplete(read.StreamId, read.BytesRead); break; @@ -175,6 +180,10 @@ public void OnBodyMessage(object msg) case DrainReadFailed failed: _pump?.HandleReadFailed(failed.StreamId, failed.Reason); break; + + case AbandonedResponseBody abandoned: + _streamManager.OnResponseBodyAbandoned(abandoned.StreamId); + break; } } @@ -383,11 +392,6 @@ public void OnOutboundFlushed() void IBodyDrainTarget.EmitDataFrames(long streamId, ReadOnlyMemory data, bool endStream) { EmitBufferedDataFrames(streamId, data, endStream); - - if (!endStream && !data.IsEmpty) - { - _pump?.AddCredit(); - } } private void EmitBufferedDataFrames(long streamId, ReadOnlyMemory body, bool endStream) diff --git a/src/GaudiHTTP/Protocol/Syntax/Http3/Client/StreamManager.cs b/src/GaudiHTTP/Protocol/Syntax/Http3/Client/StreamManager.cs index b725a8a91..313300df9 100644 --- a/src/GaudiHTTP/Protocol/Syntax/Http3/Client/StreamManager.cs +++ b/src/GaudiHTTP/Protocol/Syntax/Http3/Client/StreamManager.cs @@ -1,4 +1,5 @@ using System.Buffers; +using Akka.Actor; using Servus.Akka.Transport; using GaudiHTTP.Internal; using GaudiHTTP.Protocol.Body; @@ -204,7 +205,10 @@ public void ResolveBlockedStreams( var queued = _bodyReaderPool.Rent(() => new QueuedBodyReader(capacity: 8)); state.InitBodyReader(queued, maxResponseBodySize); var response = state.GetResponse(); - var bodyStream = state.GetBodyStream(); + var stageActor = ops.StageActor; + var capturedId = streamId; + var bodyStream = queued.AsStream(onAbandoned: () => + stageActor.Tell(new Http3ClientSessionManager.AbandonedResponseBody(capturedId), ActorRefs.NoSender)); response.Content = new StreamContent(bodyStream); state.ApplyContentHeadersTo(response.Content); @@ -404,7 +408,10 @@ private void HandleResponseHeaders(HeadersFrame frame, StreamState state) var queued = _bodyReaderPool.Rent(() => new QueuedBodyReader(capacity: 8)); state.InitBodyReader(queued, maxResponseBodySize); var response = state.GetResponse(); - var bodyStream = state.GetBodyStream(); + var stageActor = ops.StageActor; + var capturedId = streamId; + var bodyStream = queued.AsStream(onAbandoned: () => + stageActor.Tell(new Http3ClientSessionManager.AbandonedResponseBody(capturedId), ActorRefs.NoSender)); response.Content = new StreamContent(bodyStream); state.ApplyContentHeadersTo(response.Content); @@ -473,6 +480,17 @@ private void EmitResponse(long streamId) ReturnStreamState(streamId); } + public void OnResponseBodyAbandoned(long streamId) + { + if (!_streams.TryGetValue(streamId, out var state)) + { + return; + } + + AbortAndReturnBodyReader(state); + ReturnStreamState(streamId); + } + private void AbortAndReturnBodyReader(StreamState state) { var reader = state.TakeBodyReader(); diff --git a/src/GaudiHTTP/Protocol/Syntax/Http3/Server/Http3ServerSessionManager.cs b/src/GaudiHTTP/Protocol/Syntax/Http3/Server/Http3ServerSessionManager.cs index 0b83ad31b..a85c50b3c 100644 --- a/src/GaudiHTTP/Protocol/Syntax/Http3/Server/Http3ServerSessionManager.cs +++ b/src/GaudiHTTP/Protocol/Syntax/Http3/Server/Http3ServerSessionManager.cs @@ -261,6 +261,10 @@ public void OnBodyMessage(object msg) { switch (msg) { + case ContinueDrain: + _pump?.HandleContinueDrain(); + break; + case DrainReadComplete read: _pump?.HandleReadComplete(read.StreamId, read.BytesRead); break; @@ -771,11 +775,6 @@ void IBodyDrainTarget.EmitDataFrames(long streamId, ReadOnlyMemory d if (!data.IsEmpty) { EmitBufferedDataFrames(streamId, data, endStream); - - if (!endStream) - { - _pump?.AddCredit(); - } } }