diff --git a/.gitattributes b/.gitattributes
index ef6de8905..866ebabe6 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -15,5 +15,3 @@
# Other binary assets — LFS + no text diff
*.ico filter=lfs diff=lfs merge=lfs -text
*.pdf filter=lfs diff=lfs merge=lfs -text
-*.zip filter=lfs diff=lfs merge=lfs -text
-*.gz filter=lfs diff=lfs merge=lfs -text
diff --git a/CLAUDE.md b/CLAUDE.md
index 0bf7dac1c..e1cdd87d9 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -153,9 +153,11 @@ Single source of truth for all non-code knowledge. **Use Obsidian MCP tools** (`
## Performance Patterns
-- **Snapshot semantics**: Decoder/FrameDecoder return values are held across calls by tests —
- cannot return reused lists directly. Use `.ToArray()` or `new List<>(buffer)` for public APIs.
- Akka back-pressure guarantees consumption in production, but test contracts require copies.
+- **Reused decode buffers**: `FrameDecoder.Decode` returns its reused `_frames` list directly (no
+ per-read array alloc); the client/server state machines consume it synchronously within the same
+ actor message under Akka back-pressure. A caller (or test) that needs to hold a result across a
+ later `Decode` MUST snapshot it (`.ToArray()`). When adding a decoder return that is consumed
+ asynchronously or retained, copy instead — never hand out a reused buffer to such a caller.
- **List reuse pattern**: Http2/RequestEncoder has `_reusableHeaders`/`_reusableFrames` —
follow this pattern for any per-request collection (clear + repopulate, not new).
- **`string.Concat` over `$""`** for simple 2-3 part joins (avoids handler alloc)
diff --git a/docs/when-to-use.md b/docs/when-to-use.md
index de96d47bc..fbf98735f 100644
--- a/docs/when-to-use.md
+++ b/docs/when-to-use.md
@@ -1,34 +1,41 @@
# When to Use TurboHTTP
TurboHTTP is not a drop-in "faster HttpClient/Kestrel". It is an HTTP stack built on Akka.Streams
-whose strengths are **streaming, backpressure, large payloads under concurrency, and actor
-integration** — and whose trade-off is per-request overhead on tiny, latency-critical requests.
-This page summarizes where each side of the stack wins, based on the benchmark suite
-(BenchmarkDotNet, loopback, 2026-06).
+whose strengths are **HTTP/2 multiplexing, streaming, backpressure, and actor integration** — and
+whose trade-off is per-request overhead on tiny, latency-critical requests and a heavier cold start.
+This page summarizes where each side of the stack wins, based on measured BenchmarkDotNet results
+(Ryzen 7 5800X, .NET 10.0.8, loopback, 2026-06-21).
## TL;DR
| Your workload | Recommendation |
|---|---|
| Many small GETs, lowest possible latency | HttpClient / Kestrel |
-| Large request bodies (uploads) under concurrency | **TurboHTTP client** (H2/H3: up to 2–3.5× HttpClient) |
-| Upload-heavy server endpoints (HTTP/1.1) | **TurboServer** (+10–34 % vs Kestrel) |
+| HTTP/2 server endpoints (plaintext, JSON) | **TurboServer** (1.4–1.5× Kestrel) |
+| Concurrent downloads over HTTP/2 or HTTP/3 | **TurboHTTP client** (2–3.5× HttpClient) |
+| HTTP/1.1 pipelined requests on a single connection | **TurboHTTP client** (up to 4.7× HttpClient) |
| Streaming, SSE, backpressure end-to-end | **TurboHTTP (both sides)** |
| Actor-based backends (Akka.NET) | **TurboServer** — shares your `ActorSystem` |
| Bulk request pipelines (fire thousands, drain results) | **TurboHTTP client channel API** |
+| HTTP/3 (QUIC) at any scale | HttpClient / Kestrel (TurboHTTP H3 is 2–7× slower) |
## As a Client
### Where it wins
-- **Concurrent uploads over HTTP/2 and HTTP/3.** With many in-flight POSTs, the multiplexed
- upload path clearly beats `SocketsHttpHandler`: at 512–4096 concurrent 10 KB uploads the
- benchmark shows **+12 % to +58 % (H2)** and **+123 % to +247 % (H3)** throughput, with up to
- **84 % fewer allocations** (H2, CL=4096). Tail latency follows: p99 is 40–70 % lower in these
- scenarios.
-- **HTTP/1.1 uploads at scale** run close to HttpClient (within ~30–40 % at high concurrency)
- with bounded memory — the request body pump is backpressured against the socket instead of
- buffering whole bodies.
+- **HTTP/1.1 pipelining on a single connection.** At 256 concurrent requests over one connection,
+ TurboHTTP delivers **4.7× the throughput** (73K vs 15K req/s) of HttpClient. At 64 concurrent
+ it is 1.5× faster. This makes it ideal for connection-constrained scenarios and serial
+ keep-alive workloads.
+- **HTTP/2 and HTTP/3 concurrent downloads.** Downloading 1 MB payloads across 32 connections,
+ TurboHTTP is **2.3× faster on H2** (2,727 vs 1,199 req/s) and **3.5× faster on H3** (613 vs
+ 176 req/s). For 8 MB payloads the advantage holds: **2.4× on H2** and **2.9× on H3**. The
+ streams-based body consumption handles flow-controlled data more efficiently than
+ SocketsHttpHandler.
+- **HTTP/1.1 concurrent light requests at moderate scale.** At 512 concurrent light GETs,
+ TurboHTTP is **1.6× faster** (66K vs 42K req/s) than HttpClient.
+- **HTTP/2 single-connection multiplexing.** At 64 concurrent requests on one H2 connection,
+ TurboHTTP delivers **1.5× the throughput** (49K vs 33K req/s).
- **Resilience built into the pipeline.** Retries, reconnect with request replay, redirects,
cookies, HTTP caching, and content encoding are stream stages, not handler wrappers — and all
of it is observable through permanent `Servus.Senf` tracing.
@@ -39,20 +46,28 @@ This page summarizes where each side of the stack wins, based on the benchmark s
### Where HttpClient is the better tool
-- **Single-request latency on light GETs.** A lone ~3 B GET costs ~150–160 µs vs HttpClient's
- ~74 µs; light-GET fan-out at very high concurrency is also slower (H2/H3 light concurrent).
-- **The channel API has a latency floor** (~1.3–1.6 ms per isolated request) from its
- stream-materialization hops — it amortizes over bulk work, not single calls.
+- **Single-request latency.** A warm light GET costs **114 µs vs HttpClient's 67 µs** on H1.1
+ (~47 µs GraphInterpreter overhead), 123 vs 77 µs on H2, 228 vs 180 µs on H3.
+- **Cold start.** First request takes **6.4 ms vs 480 µs** (13× slower) on H1.1/H2, allocating
+ ~3 MB for actor system and streams graph materialization vs HttpClient's 33 KB.
+- **Very high concurrency (4096+).** TurboHTTP's SendAsync API currently crashes at 4096
+ concurrent requests across all protocols and at 512 for HTTP/3.
+- **HTTP/3 (QUIC) generally.** Single-connection H3 is **4.5–7.3× slower** than HttpClient.
+ This is a known transport-layer limitation being worked on.
+- **HTTP/1.1 concurrent downloads.** At 32 connections downloading 1 MB, HttpClient is **3.1×
+ faster** (11,413 vs 3,692 req/s) — the connection pool management overhead currently hurts
+ on H1.1 download workloads.
## As a Server
### Where it wins
-- **HTTP/1.1 upload endpoints.** 1 MB POSTs run **+10 % to +34 %** faster than Kestrel
- (sequential and CL=1 concurrent; +10–20 % at CL=64/256 sequential).
-- **HTTP/2 / HTTP/3 request handling at parity.** Plaintext/JSON/Fortunes sequential are within
- ±5–15 % of Kestrel across protocols; several H2 concurrent scenarios (plaintext, JSON) are
- ahead at p95/p99.
+- **HTTP/2 plaintext and JSON at high concurrency.** At 256 concurrent requests, TurboServer
+ delivers **1.5× Kestrel's throughput on plaintext** (80K vs 54K req/s) and **1.4× on JSON**
+ (79K vs 57K req/s). At 64 concurrent it is 1.2–1.3× faster. HTTP/2 multiplexing is
+ TurboServer's sweet spot.
+- **HTTP/1.1 at near-parity.** Plaintext/JSON/Fortunes are within 5–10% of Kestrel on H1.1
+ across all concurrency levels — competitive enough for most workloads.
- **Streaming responses with real backpressure.** Return an Akka Streams `Source` (SSE, long
downloads) and flow control runs end-to-end — a slow client slows the producer instead of
growing a buffer.
@@ -61,19 +76,23 @@ This page summarizes where each side of the stack wins, based on the benchmark s
### Where Kestrel is the better tool
-- **Small-response throughput/latency records.** Plaintext/JSON-style endpoints are ~6–16 %
- slower at p50 and allocate more per request (managed allocations are roughly 3–4× Kestrel's
- 2.7 KB; native/pooled buffers excluded on both sides).
-- **Very high fan-out on HTTP/3.** Light-request concurrency over QUIC currently trails Kestrel
- significantly (-50 % to -74 %) — a known limitation of the shared pipeline, being worked on.
+- **HTTP/3 (QUIC) — significantly.** TurboServer is **1.4–3.9× slower** than Kestrel across
+ all H3 workloads (JSON concurrent @256: 29K vs 114K req/s = 26% of Kestrel).
+- **Larger response bodies on HTTP/2.** The Fortunes benchmark (larger HTML responses) shows a
+ dramatic **4.2× regression** at 256 concurrent H2 requests (22K vs 92K req/s), compared to
+ plaintext/JSON where TurboServer leads. This points to a body-write or serialization
+ bottleneck specific to larger response payloads.
+- **Per-request allocations.** TurboServer allocates roughly **2.5–3× more** per request than
+ Kestrel (6.9 KB vs 2.6 KB on H1.1 plaintext). Kestrel pools its HttpContext, feature
+ collections, and header dictionaries more aggressively.
+- **Uploads at scale.** Upload endpoints are 1.3–1.4× slower on H1.1/H2.
## In Combination
Running TurboHTTP on both ends pays off when the *pipeline* is the product:
-- **Service-to-service with large payloads.** TurboHTTP client → TurboServer keeps uploads
- backpressured on both sides; neither end buffers whole bodies, so memory stays flat under
- load spikes.
+- **HTTP/2 service-to-service.** TurboHTTP client's H2 download advantage (2.3×) combined with
+ TurboServer's H2 serving advantage (1.4–1.5×) makes a compelling end-to-end H2 story.
- **End-to-end streaming.** An Akka Streams `Source` on the server feeds an Akka Streams
consumer on the client — one flow-controlled graph across the network, including SSE.
- **Gateways and proxies.** Forward-proxy and CONNECT tunneling are supported; combined with
@@ -84,9 +103,11 @@ Running TurboHTTP on both ends pays off when the *pipeline* is the product:
## Benchmark Context
-Numbers above come from the repo's benchmark suite (`TurboHTTP.Benchmarks`): localhost loopback,
-BenchmarkDotNet, HTTP/1.1 + h2c cleartext, HTTP/3 with self-signed TLS, run 2026-06. Loopback
-isolates protocol-stack overhead and exaggerates per-request costs relative to real networks —
-over WAN latencies, the gaps on light requests shrink while the streaming/backpressure advantages
-remain. Memory figures count managed allocations only. Re-run with
-`dotnet run -c Release --project TurboHTTP.Benchmarks` to reproduce on your hardware.
+Numbers above come from the repo's benchmark suite (`TurboHTTP.Benchmarks`): Ryzen 7 5800X
+(8C/16T), .NET 10.0.8, BenchmarkDotNet v0.15.8, localhost loopback, HTTP/1.1 + h2c cleartext,
+HTTP/3 with self-signed TLS, measured 2026-06-21 on branch `feat/dispatcher-analysis` after 12+
+optimization commits. Loopback isolates protocol-stack overhead and exaggerates per-request costs
+relative to real networks — over WAN latencies, the gaps on light requests shrink while the
+streaming/backpressure/multiplexing advantages remain. Memory figures count managed allocations
+only. Re-run with `dotnet run -c Release --project TurboHTTP.Benchmarks` to reproduce on your
+hardware.
diff --git a/lib/servus.akka b/lib/servus.akka
index 920fe0798..4241277fa 160000
--- a/lib/servus.akka
+++ b/lib/servus.akka
@@ -1 +1 @@
-Subproject commit 920fe07981e67504b1f1e0f30a7dabb30c707022
+Subproject commit 4241277fa93bb79c779b44270942335d81bfd3a9
diff --git a/src/TurboHTTP.IntegrationTests.Client/H11/SingleConnectionConcurrencyRegressionSpec.cs b/src/TurboHTTP.IntegrationTests.Client/H11/SingleConnectionConcurrencyRegressionSpec.cs
new file mode 100644
index 000000000..e755e0123
--- /dev/null
+++ b/src/TurboHTTP.IntegrationTests.Client/H11/SingleConnectionConcurrencyRegressionSpec.cs
@@ -0,0 +1,77 @@
+using System.Net;
+using TurboHTTP.IntegrationTests.Client.Shared;
+using TurboHTTP.Tests.Shared;
+
+namespace TurboHTTP.IntegrationTests.Client.H11;
+
+///
+/// Repro for the single-connection HTTP/1.1 concurrency deadlock in the 2026-06-19 benchmark run
+/// (KestrelTurboSingleConnectionBenchmarks [ConcurrencyLevel=64 and 256, HttpVersion=1.1] → NA).
+/// With MaxConnectionsPerServer forced to 1, the benchmark completed a few iterations of N concurrent
+/// GETs (~1.5 ms each) and then HUNG to the 60 s WaitAsync — an intermittent pipelining/dispatch
+/// deadlock when many requests share one H1.1 connection. The H2 and H3 single-connection variants
+/// produced results; only H1.1 went NA.
+///
+/// The deadlock is intermittent, so the spec drives many rounds of concurrent bursts on the single
+/// connection and fails the first round that does not drain within a generous per-round budget.
+///
+/// ROOT CAUSE (2026-06-20, fixed): under heavy pipelining the server streams many responses back to
+/// back, so a response's status line or header block is frequently split across two TCP reads. The
+/// H1.1 client decoder kept no cross-read remainder, so the unconsumed prefix of a split header was
+/// discarded and the next read's continuation parsed as garbage ("Malformed header field"), faulting
+/// that request and stranding its in-flight pipelined siblings. Fixed by retaining the unconsumed
+/// prefix in Http11ClientStateMachine (_partialResponse) and prepending it to the next read; the
+/// deterministic repro lives in Http11ClientFragmentedResponseSpec. This stress guard now drains 256 ×
+/// 40 cleanly; it failed ~50-80% of runs before the fix.
+///
+[Collection("H11")]
+public sealed class SingleConnectionConcurrencyRegressionSpec : IntegrationSpecBase
+{
+ public SingleConnectionConcurrencyRegressionSpec(ServerContainerFixture server, ActorSystemFixture systemFixture)
+ : base(server, systemFixture)
+ {
+ }
+
+ // Build our own single-connection client below; do not use the default multi-connection Client.
+ protected override ProtocolVariant? Variant => null;
+
+ [Fact(Timeout = 180_000)]
+ public async Task SingleConnection_should_not_deadlock_under_concurrent_H11_requests()
+ {
+ await using var helper = CreateClient(
+ new ProtocolVariant(TestHttpVersion.H11, tls: false),
+ configureOptions: o => o.Http1.MaxConnectionsPerServer = 1);
+ var client = helper.Client;
+
+ // 256 concurrency matches the heavier of the two NA configs ([256, 1.1]); many rounds give the
+ // intermittent single-connection deadlock repeated chances to surface.
+ const int concurrency = 256;
+ const int rounds = 40;
+
+ for (var round = 0; round < rounds; round++)
+ {
+ var tasks = new Task[concurrency];
+ for (var i = 0; i < concurrency; i++)
+ {
+ tasks[i] = client.SendAsync(
+ new HttpRequestMessage(HttpMethod.Get, "/get"), CancellationToken);
+ }
+
+ try
+ {
+ var responses = await Task.WhenAll(tasks).WaitAsync(TimeSpan.FromSeconds(15), CancellationToken);
+ Assert.All(responses, r => Assert.Equal(HttpStatusCode.OK, r.StatusCode));
+ foreach (var r in responses)
+ {
+ r.Dispose();
+ }
+ }
+ catch (TimeoutException)
+ {
+ Assert.Fail(
+ $"REPRO: round {round} of {concurrency} concurrent HTTP/1.1 GETs on a single connection " +
+ "did not complete within 15 s — single-connection request dispatch deadlocked.");
+ }
+ }
+ }
+}
diff --git a/src/TurboHTTP.IntegrationTests.Client/H2/LargeDownloadRegressionSpec.cs b/src/TurboHTTP.IntegrationTests.Client/H2/LargeDownloadRegressionSpec.cs
new file mode 100644
index 000000000..18c4d6654
--- /dev/null
+++ b/src/TurboHTTP.IntegrationTests.Client/H2/LargeDownloadRegressionSpec.cs
@@ -0,0 +1,83 @@
+using System.Net;
+using TurboHTTP.Client;
+using TurboHTTP.IntegrationTests.Client.Shared;
+using TurboHTTP.Tests.Shared;
+
+namespace TurboHTTP.IntegrationTests.Client.H2;
+
+///
+/// Repro for the HTTP/2 large-download hang in the 2026-06-19 benchmark run
+/// (KestrelTurboDownloadBenchmarks [ConcurrencyLevel=1, DownloadBytes=8388608, HttpVersion=2.0] → NA,
+/// "System.TimeoutException: The operation has timed out"). A SINGLE 8 MB response over one H2 stream
+/// hung to the 120 s WaitAsync, while 1 MB over H2 — and 8 MB over H1.1 and H3 — all completed.
+/// Suspected receive-path flow-control / WINDOW_UPDATE stall on a single large stream.
+///
+/// 1 MB is included first as a sanity check (it completes in the benchmark); the 8 MB download is the
+/// configuration that hung.
+///
+[Collection("H2")]
+public sealed class LargeDownloadRegressionSpec : IntegrationSpecBase
+{
+ public LargeDownloadRegressionSpec(ServerContainerFixture server, ActorSystemFixture systemFixture)
+ : base(server, systemFixture)
+ {
+ }
+
+ // Build our own client below so we can pin a single H2 connection (one stream at a time),
+ // matching the benchmark's ConcurrencyLevel=1 over the default pool.
+ protected override ProtocolVariant? Variant => null;
+
+ [Fact(Timeout = 180_000)]
+ public async Task LargeDownload_should_complete_8MB_body_over_single_H2_stream()
+ {
+ await using var helper = CreateClient(
+ new ProtocolVariant(TestHttpVersion.H2, tls: true),
+ configureOptions: o => o.Http2.MaxConnectionsPerServer = 1);
+ var client = helper.Client;
+
+ // Warmup + iterations: the benchmark drained 8 MB ~13 times in sequence before it hung.
+ await DownloadAsync(client, 1 * 1024 * 1024);
+ for (var i = 0; i < 13; i++)
+ {
+ await DownloadAsync(client, 8 * 1024 * 1024);
+ }
+ }
+
+ private async Task DownloadAsync(ITurboHttpClient client, int size)
+ {
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken);
+ cts.CancelAfter(TimeSpan.FromSeconds(30));
+
+ try
+ {
+ var response = await client.SendAsync(
+ new HttpRequestMessage(HttpMethod.Get, $"/bytes/{size}"), cts.Token);
+
+ // This guard needs a server that streams an arbitrary-size body. The Kestrel backend's
+ // /bytes/{n} does; the Docker (httpbin) backend caps the size and rejects it up front with 400
+ // (some servers use 413). Skip ONLY on those size-rejection statuses — any other non-200
+ // (404, 5xx, ...) is a real failure and must not be masked. The stall this guards against
+ // surfaces as the 30 s timeout below, never as a status code.
+ if (response.StatusCode is HttpStatusCode.BadRequest or HttpStatusCode.RequestEntityTooLarge)
+ {
+ response.Dispose();
+ Assert.Skip(
+ $"Backend rejected /bytes/{size} with {(int)response.StatusCode} (size cap); "
+ + "run with the Kestrel backend to exercise the H2 receive flow-control fix.");
+ return;
+ }
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ // Drain exactly like the benchmark (Content.CopyToAsync(Stream.Null)).
+ await response.Content.CopyToAsync(Stream.Null, cts.Token);
+ response.Dispose();
+ }
+ catch (OperationCanceledException) when (cts.IsCancellationRequested && !CancellationToken.IsCancellationRequested)
+ {
+ Assert.Fail(
+ $"REPRO: a {size / (1024 * 1024)} MB HTTP/2 download did not complete within 30 s — " +
+ "the receive path stalls on a large single stream (suspected missing/stuck WINDOW_UPDATE).");
+ }
+ }
+}
diff --git a/src/TurboHTTP.IntegrationTests.End2End/H10/LargePayloadSpec.cs b/src/TurboHTTP.IntegrationTests.End2End/H10/LargePayloadSpec.cs
index 4927a9680..1f54f5bfb 100644
--- a/src/TurboHTTP.IntegrationTests.End2End/H10/LargePayloadSpec.cs
+++ b/src/TurboHTTP.IntegrationTests.End2End/H10/LargePayloadSpec.cs
@@ -23,12 +23,14 @@ protected override void ConfigureEndpoints(WebApplication app)
await ctx.Request.Body.CopyToAsync(stream, ctx.RequestAborted);
var data = stream.ToArray();
ctx.Response.ContentType = "application/octet-stream";
+ ctx.Response.ContentLength = data.Length;
await ctx.Response.Body.WriteAsync(data, ctx.RequestAborted);
});
app.MapGet("/generate", async (int size, HttpContext ctx) =>
{
ctx.Response.ContentType = "application/octet-stream";
+ ctx.Response.ContentLength = size;
var buffer = new byte[1024];
Array.Fill(buffer, (byte)0xAB);
var remaining = size;
diff --git a/src/TurboHTTP.IntegrationTests.End2End/H2/SendAsyncHighConcurrencySpec.cs b/src/TurboHTTP.IntegrationTests.End2End/H2/SendAsyncHighConcurrencySpec.cs
index 6c65332a6..d7dc84ce3 100644
--- a/src/TurboHTTP.IntegrationTests.End2End/H2/SendAsyncHighConcurrencySpec.cs
+++ b/src/TurboHTTP.IntegrationTests.End2End/H2/SendAsyncHighConcurrencySpec.cs
@@ -33,7 +33,7 @@ public sealed class SendAsyncHighConcurrencySpec : IAsyncLifetime
public async ValueTask InitializeAsync()
{
- Assert.Skip("High-concurrency spec causes resource contention with parallel test collections");
+ Assert.Skip("High-concurrency spec causes resource contention with parallel test collections in CI");
// --- Kestrel server (matches benchmark BenchmarkServer config) ---
var builder = WebApplication.CreateBuilder();
@@ -626,6 +626,103 @@ public async Task SendAsync_find_heavy_stall_threshold()
}
}
+ ///
+ /// Diagnostic test: reproduces the BDN crash by firing ALL requests simultaneously
+ /// WITHOUT a SemaphoreSlim gate — exactly matching the benchmark behavior. Graduates
+ /// from 64→4096 to find the exact stall threshold.
+ ///
+ [Fact(Timeout = 300_000)]
+ public async Task Diagnose_sendAsync_stall_threshold_with_traces()
+ {
+ int[] levels = [64, 128, 256, 512, 1024, 2048, 4096];
+
+ Console.Error.WriteLine($"ThreadPool: count={ThreadPool.ThreadCount}, " +
+ $"GC={(System.Runtime.GCSettings.IsServerGC ? "Server" : "Workstation")}");
+
+ foreach (var concurrency in levels)
+ {
+ var tasks = new Task[concurrency];
+ var completed = 0;
+ var failed = 0;
+ var sw = Stopwatch.StartNew();
+ var timeout = TimeSpan.FromSeconds(Math.Max(30, concurrency / 10));
+
+ using var progressTimer = new Timer(_ =>
+ {
+ ThreadPool.GetAvailableThreads(out var workerAvail, out var ioAvail);
+ ThreadPool.GetMaxThreads(out var workerMax, out var ioMax);
+
+ Console.Error.WriteLine(
+ $"[{sw.Elapsed:mm\\:ss\\.ff}] CL={concurrency}: " +
+ $"{Volatile.Read(ref completed)}/{concurrency} done, {Volatile.Read(ref failed)} fail, " +
+ $"ThreadPool: busy={workerMax - workerAvail}/{workerMax} io={ioMax - ioAvail}/{ioMax} " +
+ $"Mem={GC.GetTotalMemory(false) / 1024 / 1024}MB");
+ }, null, TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(2));
+
+ for (var i = 0; i < concurrency; i++)
+ {
+ var request = new HttpRequestMessage(HttpMethod.Get, $"{_baseUri}/simple");
+ tasks[i] = _client!.SendAsync(request, CT).ContinueWith(t =>
+ {
+ if (t.IsCompletedSuccessfully)
+ {
+ Interlocked.Increment(ref completed);
+ }
+ else
+ {
+ Interlocked.Increment(ref failed);
+ }
+
+ return t.IsCompletedSuccessfully ? t.Result : null!;
+ }, TaskScheduler.Default);
+ }
+
+ try
+ {
+ await Task.WhenAll(tasks).WaitAsync(timeout);
+ }
+ catch (TimeoutException)
+ {
+ sw.Stop();
+
+ ThreadPool.GetAvailableThreads(out var wa, out var ia);
+ ThreadPool.GetMaxThreads(out var wm, out var im);
+
+ Console.Error.WriteLine(
+ $"=== TIMEOUT at CL={concurrency}: {completed}/{concurrency} done, {failed} fail " +
+ $"after {sw.Elapsed.TotalMilliseconds:F0}ms ===");
+ Console.Error.WriteLine(
+ $" ThreadPool: busy={wm - wa}/{wm} io={im - ia}/{im} " +
+ $"Mem={GC.GetTotalMemory(false) / 1024 / 1024}MB");
+
+ Assert.Fail(
+ $"Pipeline stalled at CL={concurrency}: only {completed}/{concurrency} " +
+ $"completed in {sw.Elapsed.TotalMilliseconds:F0}ms");
+ return;
+ }
+
+ sw.Stop();
+
+ foreach (var t in tasks)
+ {
+ if (t.IsCompletedSuccessfully && t.Result is not null)
+ {
+ t.Result.Dispose();
+ }
+ }
+
+ Console.Error.WriteLine(
+ $"CL={concurrency}: OK in {sw.Elapsed.TotalMilliseconds:F0}ms " +
+ $"({concurrency / sw.Elapsed.TotalSeconds:F0} req/s), {failed} failures");
+
+ if (failed > 0)
+ {
+ Assert.Fail($"CL={concurrency}: {failed}/{concurrency} requests failed");
+ return;
+ }
+ }
+ }
+
private sealed class Counters
{
public int Completed;
@@ -645,8 +742,7 @@ private static void ConfigureTracing()
Tracing.Configure(
new Diagnostics.LoggerTraceListener(loggerFactory),
- TraceLevel.Warning,
- category => category == "Protocol");
+ TraceLevel.Warning);
}
private sealed class FixedOptionsFactory(TurboClientOptions options) : IOptionsFactory
diff --git a/src/TurboHTTP.Tests/Internal/PendingRequestLifecycleSpec.cs b/src/TurboHTTP.Tests/Internal/PendingRequestLifecycleSpec.cs
index 5f709a651..0827e84d3 100644
--- a/src/TurboHTTP.Tests/Internal/PendingRequestLifecycleSpec.cs
+++ b/src/TurboHTTP.Tests/Internal/PendingRequestLifecycleSpec.cs
@@ -113,6 +113,35 @@ public async Task Version_guard_should_prevent_stale_pipeline_completion_after_r
PendingRequest.Return(pr2);
}
+ [Fact(Timeout = 5000)]
+ public void Attached_timeout_cts_should_be_disposed_when_completed_with_result()
+ {
+ var pr = PendingRequest.Rent();
+ var cts = new CancellationTokenSource();
+ pr.AttachTimeoutCts(cts);
+
+ pr.TrySetResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK), pr.Version);
+
+ // A disposed CancellationTokenSource throws when its Token is accessed.
+ Assert.Throws(() => _ = cts.Token);
+
+ PendingRequest.Return(pr);
+ }
+
+ [Fact(Timeout = 5000)]
+ public void Attached_timeout_cts_should_be_disposed_when_completed_with_exception()
+ {
+ var pr = PendingRequest.Rent();
+ var cts = new CancellationTokenSource();
+ pr.AttachTimeoutCts(cts);
+
+ pr.TrySetException(new InvalidOperationException("x"), pr.Version);
+
+ Assert.Throws(() => _ = cts.Token);
+
+ PendingRequest.Return(pr);
+ }
+
[Fact(Timeout = 10000)]
public async Task CancelPendingRequests_pattern_should_survive_concurrent_add_and_cancel()
{
diff --git a/src/TurboHTTP.Tests/Pooling/PooledArrayMemoryOwnerSpec.cs b/src/TurboHTTP.Tests/Pooling/PooledArrayMemoryOwnerSpec.cs
new file mode 100644
index 000000000..dfe06cf6c
--- /dev/null
+++ b/src/TurboHTTP.Tests/Pooling/PooledArrayMemoryOwnerSpec.cs
@@ -0,0 +1,77 @@
+using System.Buffers;
+using System.Runtime.InteropServices;
+using TurboHTTP.Pooling;
+
+namespace TurboHTTP.Tests.Pooling;
+
+public sealed class PooledArrayMemoryOwnerSpec
+{
+ private static byte[] BackingArray(Memory memory)
+ {
+ Assert.True(MemoryMarshal.TryGetArray(memory, out var segment));
+ return segment.Array!;
+ }
+
+ [Fact(Timeout = 5000)]
+ public void Memory_is_at_least_the_requested_length()
+ {
+ var pool = ArrayPool.Create();
+ using var owner = new PooledArrayMemoryOwner(pool, 100);
+
+ Assert.True(owner.Memory.Length >= 100);
+ }
+
+ [Fact(Timeout = 5000)]
+ public void Dispose_returns_the_array_to_the_pool_for_reuse()
+ {
+ var pool = ArrayPool.Create();
+ var owner1 = new PooledArrayMemoryOwner(pool, 1024);
+ var array1 = BackingArray(owner1.Memory);
+ owner1.Dispose();
+
+ using var owner2 = new PooledArrayMemoryOwner(pool, 1024);
+
+ Assert.Same(array1, BackingArray(owner2.Memory));
+ }
+
+ [Fact(Timeout = 5000)]
+ public async Task Buffer_returned_on_another_thread_is_reused()
+ {
+ // This is the whole point of #2: a process-wide pool with global, locked per-bucket
+ // stacks survives the connection-stage -> application thread hop, unlike the per-core
+ // MemoryPool.Shared whose return lands on a different core's stack.
+ var pool = ArrayPool.Create();
+ var owner1 = new PooledArrayMemoryOwner(pool, 4096);
+ var array1 = BackingArray(owner1.Memory);
+
+ await Task.Run(() => owner1.Dispose());
+
+ using var owner2 = new PooledArrayMemoryOwner(pool, 4096);
+
+ Assert.Same(array1, BackingArray(owner2.Memory));
+ }
+
+ [Fact(Timeout = 5000)]
+ public void Double_dispose_does_not_return_the_array_twice()
+ {
+ var pool = ArrayPool.Create();
+ var owner = new PooledArrayMemoryOwner(pool, 512);
+ owner.Dispose();
+ owner.Dispose();
+
+ // If the array were returned twice, the bucket would hold it twice and two rents
+ // could hand out the same buffer to two live owners.
+ using var a = new PooledArrayMemoryOwner(pool, 512);
+ using var b = new PooledArrayMemoryOwner(pool, 512);
+
+ Assert.NotSame(BackingArray(a.Memory), BackingArray(b.Memory));
+ }
+
+ [Fact(Timeout = 5000)]
+ public void Cross_thread_buffer_pool_rents_a_usable_owner()
+ {
+ using var owner = CrossThreadBufferPool.Rent(2048);
+
+ Assert.True(owner.Memory.Length >= 2048);
+ }
+}
diff --git a/src/TurboHTTP.Tests/Protocol/Body/QueuedBodyStreamSpec.cs b/src/TurboHTTP.Tests/Protocol/Body/QueuedBodyStreamSpec.cs
new file mode 100644
index 000000000..f9c49a44a
--- /dev/null
+++ b/src/TurboHTTP.Tests/Protocol/Body/QueuedBodyStreamSpec.cs
@@ -0,0 +1,103 @@
+using System.Buffers;
+using TurboHTTP.Protocol.Body;
+
+namespace TurboHTTP.Tests.Protocol.Body;
+
+///
+/// Behaviour of the wrapper, focused on CopyToAsync:
+/// it writes pooled chunks straight to the destination (no intermediate framework buffer) while
+/// preserving exact bytes, draining a prior partial read, and returning every rental exactly once.
+///
+public sealed class QueuedBodyStreamSpec
+{
+ [Fact(Timeout = 5000)]
+ public async Task CopyToAsync_should_copy_the_full_body_and_return_every_rental()
+ {
+ var pool = new TrackingArrayPool();
+ var reader = new QueuedBodyReader(8, pool);
+ reader.TryEnqueue("one"u8);
+ reader.TryEnqueue("two"u8);
+ reader.TryEnqueue("three"u8);
+ reader.Complete();
+
+ using var destination = new MemoryStream();
+ await reader.AsStream().CopyToAsync(destination, TestContext.Current.CancellationToken);
+
+ Assert.Equal("onetwothree"u8.ToArray(), destination.ToArray());
+ Assert.Equal(pool.RentedCount, pool.ReturnedCount);
+ Assert.Equal(3, pool.RentedCount);
+ }
+
+ [Fact(Timeout = 5000)]
+ public async Task CopyToAsync_should_copy_the_remainder_after_a_partial_read()
+ {
+ var pool = new TrackingArrayPool();
+ var reader = new QueuedBodyReader(8, pool);
+ reader.TryEnqueue("abcdef"u8);
+ reader.TryEnqueue("ghij"u8);
+ reader.Complete();
+
+ var stream = reader.AsStream();
+
+ var head = new byte[2];
+ var read = await stream.ReadAsync(head, TestContext.Current.CancellationToken);
+ Assert.Equal(2, read);
+ Assert.Equal("ab"u8.ToArray(), head);
+
+ using var destination = new MemoryStream();
+ await stream.CopyToAsync(destination, TestContext.Current.CancellationToken);
+
+ // Exactly the bytes after the partial read — no dropped or duplicated bytes.
+ Assert.Equal("cdefghij"u8.ToArray(), destination.ToArray());
+ Assert.Equal(pool.RentedCount, pool.ReturnedCount);
+ }
+
+ [Fact(Timeout = 5000)]
+ public async Task CopyToAsync_should_write_nothing_when_the_body_is_already_consumed()
+ {
+ var reader = new QueuedBodyReader(8);
+ reader.TryEnqueue("payload"u8);
+ reader.Complete();
+
+ var stream = reader.AsStream();
+ using var first = new MemoryStream();
+ await stream.CopyToAsync(first, TestContext.Current.CancellationToken);
+ Assert.Equal("payload"u8.ToArray(), first.ToArray());
+
+ using var second = new MemoryStream();
+ await stream.CopyToAsync(second, TestContext.Current.CancellationToken);
+ Assert.Empty(second.ToArray());
+ }
+
+ [Fact(Timeout = 5000)]
+ public async Task CopyToAsync_should_write_nothing_for_an_empty_body()
+ {
+ var reader = new QueuedBodyReader(8);
+ reader.Complete();
+
+ using var destination = new MemoryStream();
+ await reader.AsStream().CopyToAsync(destination, TestContext.Current.CancellationToken);
+
+ Assert.Empty(destination.ToArray());
+ }
+
+ private sealed class TrackingArrayPool : ArrayPool
+ {
+ private readonly ArrayPool _inner = Shared;
+
+ public int RentedCount { get; private set; }
+ public int ReturnedCount { get; private set; }
+
+ public override byte[] Rent(int minimumLength)
+ {
+ RentedCount++;
+ return _inner.Rent(minimumLength);
+ }
+
+ public override void Return(byte[] array, bool clearArray = false)
+ {
+ ReturnedCount++;
+ _inner.Return(array, clearArray);
+ }
+ }
+}
diff --git a/src/TurboHTTP.Tests/Protocol/LineBased/BufferSearchSpec.cs b/src/TurboHTTP.Tests/Protocol/LineBased/BufferSearchSpec.cs
index 327c6670a..d42915fd5 100644
--- a/src/TurboHTTP.Tests/Protocol/LineBased/BufferSearchSpec.cs
+++ b/src/TurboHTTP.Tests/Protocol/LineBased/BufferSearchSpec.cs
@@ -25,6 +25,27 @@ public void FindCrlf_should_skip_to_start_offset()
Assert.Equal(13, BufferSearch.FindCrlf(data, 7));
}
+ [Fact(Timeout = 5000)]
+ public void FindCrlf_should_return_negative_when_lone_cr_without_lf()
+ {
+ var data = "abc\rdef"u8.ToArray();
+ Assert.Equal(-1, BufferSearch.FindCrlf(data, 0));
+ }
+
+ [Fact(Timeout = 5000)]
+ public void FindCrlf_should_skip_lone_cr_and_find_following_crlf()
+ {
+ var data = "a\rb\r\nc"u8.ToArray();
+ Assert.Equal(3, BufferSearch.FindCrlf(data, 0));
+ }
+
+ [Fact(Timeout = 5000)]
+ public void FindCrlf_should_find_crlf_when_cr_repeats_before_lf()
+ {
+ var data = "x\r\r\ny"u8.ToArray();
+ Assert.Equal(2, BufferSearch.FindCrlf(data, 0));
+ }
+
[Fact(Timeout = 5000)]
public void FindCrlfCrlf_should_find_double_crlf()
{
diff --git a/src/TurboHTTP.Tests/Protocol/Multiplexed/FlowControllerSpec.cs b/src/TurboHTTP.Tests/Protocol/Multiplexed/FlowControllerSpec.cs
index ed382f83a..ee8341dc9 100644
--- a/src/TurboHTTP.Tests/Protocol/Multiplexed/FlowControllerSpec.cs
+++ b/src/TurboHTTP.Tests/Protocol/Multiplexed/FlowControllerSpec.cs
@@ -29,6 +29,34 @@ public void FlowController_should_detect_stream_flow_control_violation()
Assert.Equal(1, result.ViolationStreamId);
}
+ [Fact(Timeout = 5000)]
+ public void ApplyInitialWindowSizeDelta_should_update_all_stream_windows()
+ {
+ var fc = new FlowController(connectionWindowSize: 65535, streamWindowSize: 65535);
+ fc.InitStreamSendWindow(1);
+ fc.InitStreamSendWindow(3);
+
+ fc.ApplyInitialWindowSizeDelta(100);
+
+ Assert.Equal(65535 + 100, fc.GetStreamSendWindow(1));
+ Assert.Equal(65535 + 100, fc.GetStreamSendWindow(3));
+ }
+
+ [Fact(Timeout = 5000)]
+ public void ApplyInitialWindowSizeDelta_should_not_allocate()
+ {
+ var fc = new FlowController(connectionWindowSize: 65535, streamWindowSize: 65535);
+ fc.InitStreamSendWindow(1);
+ fc.InitStreamSendWindow(3);
+ fc.ApplyInitialWindowSizeDelta(1);
+
+ var before = GC.GetAllocatedBytesForCurrentThread();
+ fc.ApplyInitialWindowSizeDelta(1);
+ var allocated = GC.GetAllocatedBytesForCurrentThread() - before;
+
+ Assert.Equal(0, allocated);
+ }
+
[Fact(Timeout = 5000)]
public void FlowController_should_batch_window_updates()
{
diff --git a/src/TurboHTTP.Tests/Protocol/Semantics/Connection/ConnectionSemanticsSpec.cs b/src/TurboHTTP.Tests/Protocol/Semantics/Connection/ConnectionSemanticsSpec.cs
index cf8acd337..081d23d0d 100644
--- a/src/TurboHTTP.Tests/Protocol/Semantics/Connection/ConnectionSemanticsSpec.cs
+++ b/src/TurboHTTP.Tests/Protocol/Semantics/Connection/ConnectionSemanticsSpec.cs
@@ -35,6 +35,24 @@ public void IsPersistent_should_be_false_on_HTTP11_with_connection_close()
Assert.False(ConnectionSemantics.IsPersistent(h, HttpVersion.Version11));
}
+ [Fact(Timeout = 5000)]
+ public void IsPersistent_should_not_allocate_when_no_connection_header()
+ {
+ // The common case: no Connection header. The previous GetValues(...) yield-iterator
+ // allocated an enumerator state machine on every call regardless; the index loop does not.
+ var headers = new HeaderCollection { { "Host", "x" }, { "Content-Length", "0" } };
+ _ = ConnectionSemantics.IsPersistent(headers, HttpVersion.Version11);
+
+ var before = GC.GetAllocatedBytesForCurrentThread();
+ for (var i = 0; i < 1000; i++)
+ {
+ _ = ConnectionSemantics.IsPersistent(headers, HttpVersion.Version11);
+ }
+
+ var allocated = GC.GetAllocatedBytesForCurrentThread() - before;
+ Assert.Equal(0, allocated);
+ }
+
[Theory(Timeout = 5000)]
[InlineData("Connection"), InlineData("Keep-Alive"), InlineData("Transfer-Encoding")]
[InlineData("TE"), InlineData("Upgrade"), InlineData("Proxy-Authenticate")]
diff --git a/src/TurboHTTP.Tests/Protocol/Semantics/Headers/HeaderCollectionSpec.cs b/src/TurboHTTP.Tests/Protocol/Semantics/Headers/HeaderCollectionSpec.cs
index 8c4ef72e4..cd00f037e 100644
--- a/src/TurboHTTP.Tests/Protocol/Semantics/Headers/HeaderCollectionSpec.cs
+++ b/src/TurboHTTP.Tests/Protocol/Semantics/Headers/HeaderCollectionSpec.cs
@@ -74,4 +74,36 @@ public void HeaderCollection_should_clear_all_entries()
headers.Clear();
Assert.Equal(0, headers.Count);
}
+
+ [Fact(Timeout = 5000)]
+ public void GetCombined_should_return_the_single_value_instance_without_copying()
+ {
+ // A fresh, non-interned instance so Assert.Same proves no StringBuilder copy was made for
+ // the single-value case (the common Content-Length / Transfer-Encoding path).
+ var value = new string("text/html".ToCharArray());
+ var headers = new HeaderCollection { { "Content-Type", value } };
+
+ Assert.Same(value, headers.GetCombined("Content-Type"));
+ }
+
+ [Fact(Timeout = 5000)]
+ public void Foreach_should_not_allocate_a_boxed_enumerator()
+ {
+ var headers = new HeaderCollection { { "A", "1" }, { "B", "2" } };
+ foreach (var h in headers)
+ {
+ _ = h.Name;
+ }
+
+ var before = GC.GetAllocatedBytesForCurrentThread();
+ var count = 0;
+ foreach (var h in headers)
+ {
+ count++;
+ }
+
+ var allocated = GC.GetAllocatedBytesForCurrentThread() - before;
+ Assert.Equal(2, count);
+ Assert.Equal(0, allocated);
+ }
}
\ No newline at end of file
diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Client/Http11ClientFragmentedResponseSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Client/Http11ClientFragmentedResponseSpec.cs
new file mode 100644
index 000000000..81282315d
--- /dev/null
+++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Client/Http11ClientFragmentedResponseSpec.cs
@@ -0,0 +1,93 @@
+using System.Net;
+using System.Text;
+using Servus.Akka.Transport;
+using TurboHTTP.Client;
+using TurboHTTP.Protocol.Syntax.Http11.Client;
+using TurboHTTP.Tests.Shared;
+
+namespace TurboHTTP.Tests.Protocol.Syntax.Http11.Client;
+
+///
+/// A single H1.1 connection delivers responses in TCP-sized reads, so a response's status line or
+/// header block can be split across two reads. Each read is a separate transport buffer that the
+/// state machine disposes after feeding it. The decoder must retain the unconsumed prefix and
+/// resume from it on the next read — otherwise the partial bytes are lost and the continuation is
+/// parsed as garbage ("Malformed header field"), desyncing the connection. This was the trigger for
+/// the intermittent single-connection pipelining deadlock.
+///
+public sealed class Http11ClientFragmentedResponseSpec
+{
+ private static TurboClientOptions MakeConfig()
+ => new() { Http1 = new Http1ClientOptions { MaxPipelineDepth = 256 } };
+
+ private static HttpRequestMessage MakeRequest(string path = "/")
+ => new(HttpMethod.Get, $"http://example.com{path}") { Version = new Version(1, 1) };
+
+ private static TransportBuffer Buf(string s)
+ {
+ var bytes = Encoding.ASCII.GetBytes(s);
+ var buffer = TransportBuffer.Rent(bytes.Length);
+ bytes.CopyTo(buffer.FullMemory.Span);
+ buffer.Length = bytes.Length;
+ return buffer;
+ }
+
+ [Fact(Timeout = 5000)]
+ [Trait("RFC", "RFC9112-2.2")]
+ public void DecodeServerData_should_decode_response_when_header_line_split_across_two_reads()
+ {
+ var ops = new FakeClientOps();
+ var sm = new Http11ClientStateMachine(ops, MakeConfig());
+ sm.OnRequest(MakeRequest());
+
+ const string full = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\n\r\nOK";
+ const int split = 35; // mid "Content-Type: appl|ication/json"
+
+ sm.DecodeServerData(TransportData.Rent(Buf(full[..split])));
+ sm.DecodeServerData(TransportData.Rent(Buf(full[split..])));
+
+ Assert.Single(ops.Responses);
+ Assert.Equal((int)HttpStatusCode.OK, (int)ops.Responses[0].StatusCode);
+ }
+
+ [Fact(Timeout = 5000)]
+ [Trait("RFC", "RFC9112-2.2")]
+ public void DecodeServerData_should_decode_response_when_status_line_split_across_two_reads()
+ {
+ var ops = new FakeClientOps();
+ var sm = new Http11ClientStateMachine(ops, MakeConfig());
+ sm.OnRequest(MakeRequest());
+
+ const string full = "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK";
+ const int split = 11; // mid "HTTP/1.1 20|0 OK"
+
+ sm.DecodeServerData(TransportData.Rent(Buf(full[..split])));
+ sm.DecodeServerData(TransportData.Rent(Buf(full[split..])));
+
+ Assert.Single(ops.Responses);
+ Assert.Equal((int)HttpStatusCode.OK, (int)ops.Responses[0].StatusCode);
+ }
+
+ [Fact(Timeout = 5000)]
+ [Trait("RFC", "RFC9112-9.3")]
+ public void DecodeServerData_should_decode_second_pipelined_response_when_split_after_first()
+ {
+ var ops = new FakeClientOps();
+ var sm = new Http11ClientStateMachine(ops, MakeConfig());
+ sm.OnRequest(MakeRequest("/1"));
+ sm.OnRequest(MakeRequest("/2"));
+
+ // First response complete; second response's headers split across the read boundary.
+ const string full =
+ "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK" +
+ "HTTP/1.1 201 Created\r\nContent-Length: 7\r\n\r\nCreated";
+ const int split = 55; // somewhere inside the second response's header block
+
+ sm.DecodeServerData(TransportData.Rent(Buf(full[..split])));
+ sm.DecodeServerData(TransportData.Rent(Buf(full[split..])));
+
+ Assert.Equal(2, ops.Responses.Count);
+ Assert.Equal((int)HttpStatusCode.OK, (int)ops.Responses[0].StatusCode);
+ Assert.Equal((int)HttpStatusCode.Created, (int)ops.Responses[1].StatusCode);
+ }
+}
diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Client/Http11ClientReceiveBackpressureSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Client/Http11ClientReceiveBackpressureSpec.cs
new file mode 100644
index 000000000..d21412b7d
--- /dev/null
+++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Client/Http11ClientReceiveBackpressureSpec.cs
@@ -0,0 +1,98 @@
+using System.Text;
+using Servus.Akka.Transport;
+using TurboHTTP.Client;
+using TurboHTTP.Protocol.Syntax.Http11.Client;
+using TurboHTTP.Tests.Shared;
+
+namespace TurboHTTP.Tests.Protocol.Syntax.Http11.Client;
+
+///
+/// Characterizes the H1.1 client RECEIVE-side back-pressure latch surfaced by the single-connection
+/// download analysis (#4). A STREAMED (chunked) response body fills the
+/// and latches ShouldPauseNetwork, which gates every socket Pull(_inServer). The ONLY
+/// path that releases the latch is the application reading the body
+/// (QueuedBodyReader.AdvanceTo → SlotFreed).
+///
+/// LATENT DEFECT (first test): the caller's other natural action — using var response, i.e.
+/// disposing the response WITHOUT reading the body — does NOT release the latch, because
+/// QueuedBodyStream has no Dispose override. On a single H1.1 connection (MaxConnections=1)
+/// the connection then stays paused forever and every pipelined sibling is stranded. The fix is to make
+/// disposing the body drain/cancel the reader (and drain the rest of the body off the wire) so the
+/// connection can resume.
+///
+/// NOTE: this is NOT the cause of the benchmark's H1.1 hang — that workload's response is a 3-byte
+/// Content-Length body (buffered path), confirmed by curl. This spec guards the streaming path only.
+///
+public sealed class Http11ClientReceiveBackpressureSpec
+{
+ private static TransportData Inbound(string ascii)
+ {
+ var bytes = Encoding.ASCII.GetBytes(ascii);
+ var buf = TransportBuffer.Rent(bytes.Length);
+ bytes.CopyTo(buf.FullMemory.Span);
+ buf.Length = bytes.Length;
+ return TransportData.Rent(buf);
+ }
+
+ // A chunked response with `chunks` 4-byte chunks, deliberately NOT terminated (no "0\r\n\r\n"),
+ // so the body stays mid-stream with the receive queue full.
+ private static string ChunkedResponse(int chunks)
+ {
+ var sb = new StringBuilder("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n");
+ for (var i = 0; i < chunks; i++)
+ {
+ sb.Append("4\r\nDATA\r\n");
+ }
+
+ return sb.ToString();
+ }
+
+ private static (Http11ClientStateMachine Sm, FakeClientOps Ops) NewClientWithStreamingResponse(int chunks)
+ {
+ var ops = new FakeClientOps();
+ var sm = new Http11ClientStateMachine(ops, new TurboClientOptions());
+ sm.PreStart();
+ sm.OnRequest(new HttpRequestMessage(HttpMethod.Get, "http://example.com/download") { Version = new Version(1, 1) });
+
+ sm.DecodeServerData(Inbound(ChunkedResponse(chunks)));
+
+ Assert.Single(ops.Responses);
+ Assert.True(sm.ShouldPauseNetwork,
+ "a full streamed-body receive queue must latch ShouldPauseNetwork (back-pressure)");
+ return (sm, ops);
+ }
+
+ [Fact(Timeout = 5000)]
+ public void Disposing_an_unread_streamed_response_does_not_release_receive_backpressure_LATENT_DEFECT()
+ {
+ var (sm, ops) = NewClientWithStreamingResponse(chunks: 64);
+
+ // `using var response` — caller is done with the response but never read its body.
+ ops.Responses[0].Dispose();
+
+ // CURRENT (defective) behavior: still paused. QueuedBodyStream.Dispose is a no-op, so the reader
+ // is never drained and the single H1.1 connection stays wedged. When the fix lands (dispose
+ // drains/cancels the body), flip this to Assert.False.
+ Assert.True(sm.ShouldPauseNetwork,
+ "DEFECT: disposing an unread streamed response leaves the connection paused — it should release "
+ + "back-pressure so a single H1.1 connection is not permanently stranded");
+ }
+
+ [Fact(Timeout = 5000)]
+ public void Reading_the_streamed_body_releases_receive_backpressure()
+ {
+ var (sm, ops) = NewClientWithStreamingResponse(chunks: 64);
+
+ // Draining the body via the consumer is the one path that DOES release the latch.
+ var stream = ops.Responses[0].Content.ReadAsStream();
+ var buf = new byte[4];
+ var guard = 0;
+ while (sm.ShouldPauseNetwork && guard++ < 64)
+ {
+ _ = stream.Read(buf, 0, buf.Length);
+ }
+
+ Assert.False(sm.ShouldPauseNetwork,
+ "reading the body must drain the queue below the back-pressure threshold and resume the network");
+ }
+}
diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11ServerBufferedResponseCoalesceSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11ServerBufferedResponseCoalesceSpec.cs
new file mode 100644
index 000000000..3803ba0c6
--- /dev/null
+++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11ServerBufferedResponseCoalesceSpec.cs
@@ -0,0 +1,97 @@
+using System.Text;
+using Microsoft.AspNetCore.Http.Features;
+using Microsoft.Extensions.Primitives;
+using Servus.Akka.Transport;
+using TurboHTTP.Protocol.Syntax.Http11.Server;
+using TurboHTTP.Server;
+using TurboHTTP.Server.Context.Features;
+using TurboHTTP.Tests.Shared;
+
+namespace TurboHTTP.Tests.Protocol.Syntax.Http11.Server;
+
+public sealed class Http11ServerBufferedResponseCoalesceSpec
+{
+ private static Http11ServerStateMachine CreateSm(FakeServerOps ops)
+ => new(new TurboServerOptions().ToHttp1Options(), new TurboServerOptions().ToHttp2Options(), ops);
+
+ private static void SendRequest(Http11ServerStateMachine sm)
+ {
+ var data = Encoding.ASCII.GetBytes("GET / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n");
+ var buffer = TransportBuffer.Rent(data.Length);
+ data.CopyTo(buffer.FullMemory.Span);
+ buffer.Length = data.Length;
+ sm.DecodeClientData(TransportData.Rent(buffer));
+ }
+
+ private static IFeatureCollection BufferedResponse(byte[] body, bool withContentLength)
+ {
+ var features = new TurboFeatureCollection();
+ features.Set(new TurboHttpRequestFeature { Method = "GET" });
+
+ var responseFeature = new TurboHttpResponseFeature { StatusCode = 200 };
+ if (withContentLength)
+ {
+ responseFeature.Headers["Content-Length"] = new StringValues(body.Length.ToString());
+ }
+
+ features.Set(responseFeature);
+
+ // A fully-buffered, completed response body (the dominant Plaintext/Json case): written to
+ // the buffer writer and completed without ever upgrading to a pipe, so TryGetBufferedBody
+ // hands the bytes back synchronously.
+ var bodyFeature = new TurboHttpResponseBodyFeature();
+ var span = bodyFeature.Writer.GetSpan(body.Length);
+ body.CopyTo(span);
+ bodyFeature.Writer.Advance(body.Length);
+ bodyFeature.Complete();
+ features.Set(bodyFeature);
+
+ return features;
+ }
+
+ [Fact(Timeout = 5000)]
+ public void Buffered_content_length_response_is_emitted_as_a_single_outbound()
+ {
+ var ops = new FakeServerOps();
+ var sm = CreateSm(ops);
+ SendRequest(sm);
+
+ var body = "hello world"u8.ToArray();
+ sm.OnResponse(BufferedResponse(body, withContentLength: true));
+
+ var item = Assert.Single(ops.Outbound);
+ var data = Assert.IsType(item);
+ var text = Encoding.ASCII.GetString(data.Buffer.Span);
+
+ Assert.Contains("HTTP/1.1 200", text);
+ Assert.Contains("Content-Length: 11", text);
+ Assert.EndsWith("hello world", text);
+ }
+
+ [Fact(Timeout = 5000)]
+ public void Buffered_coalesced_response_still_signals_body_complete()
+ {
+ var ops = new FakeServerOps();
+ var sm = CreateSm(ops);
+ SendRequest(sm);
+
+ var features = BufferedResponse("xyz"u8.ToArray(), withContentLength: true);
+ sm.OnResponse(features);
+
+ Assert.Contains(features, ops.ResponseBodyCompletions);
+ }
+
+ [Fact(Timeout = 5000)]
+ public void Chunked_buffered_response_is_not_coalesced()
+ {
+ var ops = new FakeServerOps();
+ var sm = CreateSm(ops);
+ SendRequest(sm);
+
+ // No Content-Length -> chunked framing: status/header buffer, framed chunk, and the
+ // zero-length terminator stay as separate outbound items.
+ sm.OnResponse(BufferedResponse("hello world"u8.ToArray(), withContentLength: false));
+
+ Assert.True(ops.Outbound.Count > 1);
+ }
+}
diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11ServerResponseHeaderScanSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11ServerResponseHeaderScanSpec.cs
new file mode 100644
index 000000000..573e5057d
--- /dev/null
+++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http11/Server/Http11ServerResponseHeaderScanSpec.cs
@@ -0,0 +1,68 @@
+using Microsoft.AspNetCore.Http.Features;
+using Microsoft.Extensions.Primitives;
+using TurboHTTP.Protocol.Syntax.Http11.Server;
+using TurboHTTP.Server.Context.Features;
+
+namespace TurboHTTP.Tests.Protocol.Syntax.Http11.Server;
+
+public sealed class Http11ServerResponseHeaderScanSpec
+{
+ private static IHttpResponseFeature Feature(params (string Name, string Value)[] headers)
+ {
+ var feature = new TurboHttpResponseFeature { StatusCode = 200 };
+ foreach (var (name, value) in headers)
+ {
+ feature.Headers[name] = new StringValues(value);
+ }
+
+ return feature;
+ }
+
+ [Fact(Timeout = 5000)]
+ public void Scan_should_extract_content_length()
+ {
+ var scan = Http11ServerStateMachine.ScanResponseHeaders(Feature(("Content-Length", "42")));
+
+ Assert.Equal(42, scan.ContentLength);
+ Assert.False(scan.HasExplicitChunked);
+ Assert.True(scan.EstimatedSize >= 256);
+ }
+
+ [Fact(Timeout = 5000)]
+ public void Scan_should_detect_explicit_chunked()
+ {
+ var scan = Http11ServerStateMachine.ScanResponseHeaders(Feature(("Transfer-Encoding", "chunked")));
+
+ Assert.Null(scan.ContentLength);
+ Assert.True(scan.HasExplicitChunked);
+ }
+
+ [Fact(Timeout = 5000)]
+ public void Scan_should_ignore_unparsable_content_length()
+ {
+ var scan = Http11ServerStateMachine.ScanResponseHeaders(Feature(("Content-Length", "notanumber")));
+
+ Assert.Null(scan.ContentLength);
+ }
+
+ [Fact(Timeout = 5000)]
+ public void Scan_should_return_floor_estimate_for_null_headers()
+ {
+ var scan = Http11ServerStateMachine.ScanResponseHeaders(null);
+
+ Assert.Null(scan.ContentLength);
+ Assert.False(scan.HasExplicitChunked);
+ Assert.Equal(256, scan.EstimatedSize);
+ }
+
+ [Fact(Timeout = 5000)]
+ public void Scan_should_grow_estimate_with_header_bytes()
+ {
+ var small = Http11ServerStateMachine.ScanResponseHeaders(Feature(("X-A", "1")));
+ var big = Http11ServerStateMachine.ScanResponseHeaders(Feature(
+ ("X-A", "1"),
+ ("X-Some-Longer-Header-Name", new string('v', 400))));
+
+ Assert.True(big.EstimatedSize > small.EstimatedSize);
+ }
+}
diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/FlowControl/Http2FlowControlSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/FlowControl/Http2FlowControlSpec.cs
index a1d381eb4..879ffba4d 100644
--- a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/FlowControl/Http2FlowControlSpec.cs
+++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/FlowControl/Http2FlowControlSpec.cs
@@ -242,7 +242,9 @@ public void Http2FrameDecoder_should_decode_across_two_calls_when_window_update_
var part2 = bytes[7..];
var decoder = new FrameDecoder();
- var frames1 = decoder.Decode(part1);
+ // Decode returns the decoder's reused list, so snapshot the first result before the second
+ // Decode call repopulates it (frames1 is asserted after frames2 is decoded).
+ var frames1 = decoder.Decode(part1).ToArray();
var frames2 = decoder.Decode(part2);
Assert.Empty(frames1); // incomplete
diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/StateMachine/Http2CrossComponentFrameSemanticsSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/StateMachine/Http2CrossComponentFrameSemanticsSpec.cs
index ce7381a97..2c1891f36 100644
--- a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/StateMachine/Http2CrossComponentFrameSemanticsSpec.cs
+++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/StateMachine/Http2CrossComponentFrameSemanticsSpec.cs
@@ -122,9 +122,10 @@ public void Http2FrameDecoder_should_decrement_active_stream_count_when_rst_stre
var openStreams = new HashSet();
var closedStreams = new HashSet();
- // Open 2 streams
- var h1 = decoder.Decode(BuildHeadersFrame(1, ValidStatusHeaderBlock()));
- var h3 = decoder.Decode(BuildHeadersFrame(3, ValidStatusHeaderBlock()));
+ // Open 2 streams. Decode returns the decoder's reused list, so snapshot each result
+ // before the next Decode call repopulates it.
+ var h1 = decoder.Decode(BuildHeadersFrame(1, ValidStatusHeaderBlock())).ToArray();
+ var h3 = decoder.Decode(BuildHeadersFrame(3, ValidStatusHeaderBlock())).ToArray();
var frame1 = Assert.IsType(h1[0]);
var frame3 = Assert.IsType(h3[0]);
diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/StateMachine/Http2StateMachineReconnectSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/StateMachine/Http2StateMachineReconnectSpec.cs
index ebabe2605..c79282efe 100644
--- a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/StateMachine/Http2StateMachineReconnectSpec.cs
+++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Client/StateMachine/Http2StateMachineReconnectSpec.cs
@@ -43,6 +43,16 @@ private static (HttpRequestMessage Request, PendingRequest Pending) MakeTrackedG
return (req, pending);
}
+ private static (HttpRequestMessage Request, PendingRequest Pending) MakeTrackedPost(string path = "/")
+ {
+ var pending = PendingRequest.Rent();
+ var version = pending.Version;
+ var req = new HttpRequestMessage(HttpMethod.Post, $"https://example.com{path}");
+ req.Options.Set(OptionsKey.Key, pending);
+ req.Options.Set(OptionsKey.VersionKey, version);
+ return (req, pending);
+ }
+
private static readonly ConnectionInfo DummyConnectionInfo = new(
new IPEndPoint(IPAddress.Loopback, 5000),
new IPEndPoint(IPAddress.Loopback, 443),
@@ -77,7 +87,9 @@ public void DecodeServerData_should_not_replay_non_idempotent_requests()
sm.OnRequest(MakePost("/b")); // stream 3
ops.Outbound.Clear();
- var goaway = new GoAwayFrame(3, Http2ErrorCode.NoError);
+ // A non-graceful (error) GOAWAY forces a reconnect; the idempotent GET is replayed but the
+ // non-idempotent POST must NOT be (the server may have partially processed it).
+ var goaway = new GoAwayFrame(3, Http2ErrorCode.InternalError);
sm.DecodeServerData(TransportData.Rent(SerializeFrame(goaway)));
Assert.True(sm.IsReconnecting);
@@ -151,4 +163,63 @@ public void DecodeServerData_should_emit_new_connect_when_reconnect_under_limit(
Assert.True(sm.IsReconnecting);
Assert.Equal(countAfterFirst + 1, ops.Outbound.OfType().Count());
}
+
+ [Fact(Timeout = 5000)]
+ [Trait("RFC", "RFC9113-6.8")]
+ public void Graceful_goaway_should_drain_inflight_streams_at_or_below_last_id_without_reconnecting()
+ {
+ // RFC 9113 §6.8: "Activity on streams numbered lower than or equal to the last stream
+ // identifier might still complete successfully ... maintaining the connection in an 'open'
+ // state until all in-progress streams complete." A graceful (NO_ERROR) GOAWAY whose
+ // LastStreamId covers all in-flight streams must NOT trigger a reconnect, and must NOT drop
+ // the in-flight non-idempotent POST — the server has committed to finish it.
+ var ops = new FakeClientOps();
+ var sm = new Http2ClientStateMachine(MakeConfig(), ops);
+ sm.PreStart();
+ sm.OnRequest(MakeGet("/a")); // stream 1
+ var (post, postPending) = MakeTrackedPost("/b"); // stream 3
+ sm.OnRequest(post);
+ ops.Outbound.Clear();
+
+ var goaway = new GoAwayFrame(3, Http2ErrorCode.NoError);
+ sm.DecodeServerData(TransportData.Rent(SerializeFrame(goaway)));
+
+ Assert.False(sm.IsReconnecting); // no eager reconnect
+ Assert.False(sm.CanAcceptRequest); // ...but no NEW streams either
+ Assert.DoesNotContain(ops.Outbound, o => o is ConnectTransport);
+ Assert.False(postPending.GetValueTask().IsCompleted, // the POST is NOT dropped — still draining
+ "graceful GOAWAY must not drop an in-flight stream <= LastStreamId");
+ }
+
+ [Fact(Timeout = 5000)]
+ [Trait("RFC", "RFC9113-6.8")]
+ public void Graceful_goaway_then_close_should_replay_streams_above_last_id_even_when_non_idempotent()
+ {
+ // The race case, handled by deferred reconnect: a POST on a stream the server discarded
+ // (id > LastStreamId) must be replayable — the server provably never processed it. The graceful
+ // GOAWAY first lets the connection drain (no eager reconnect); only when the server CLOSES the
+ // connection do we reconnect, classifying against the remembered LastStreamId so the
+ // > LastStreamId POST is replayed while a <= LastStreamId POST that never completed is not.
+ var ops = new FakeClientOps();
+ var sm = new Http2ClientStateMachine(MakeConfig(), ops);
+ sm.PreStart();
+ var (postLow, postLowPending) = MakeTrackedPost("/a"); // stream 1 (<= LastStreamId)
+ sm.OnRequest(postLow);
+ sm.OnRequest(MakePost("/b")); // stream 3 (> LastStreamId)
+ ops.Outbound.Clear();
+
+ // Phase 1 — graceful GOAWAY(LastStreamId=1): drain, no reconnect, nothing dropped yet.
+ sm.DecodeServerData(TransportData.Rent(SerializeFrame(new GoAwayFrame(1, Http2ErrorCode.NoError))));
+ Assert.False(sm.IsReconnecting);
+ Assert.DoesNotContain(ops.Outbound, o => o is ConnectTransport);
+ Assert.False(postLowPending.GetValueTask().IsCompleted);
+
+ // Phase 2 — server closes the drained connection: reconnect + replay. Stream 3 (> 1) is replayed
+ // even though it's a POST; stream 1 (<= 1, non-idempotent, never completed) is dropped.
+ sm.DecodeServerData(new TransportDisconnected(DisconnectReason.Graceful));
+ Assert.True(sm.IsReconnecting);
+ Assert.Equal(1, sm.ReconnectBufferCount); // only stream 3 buffered for replay
+ Assert.Contains(ops.Outbound, o => o is ConnectTransport);
+ Assert.True(postLowPending.GetValueTask().IsFaulted); // stream 1 dropped (may have been processed)
+ }
}
\ No newline at end of file
diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Frames/Http2ContinuationFrameErrorSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Frames/Http2ContinuationFrameErrorSpec.cs
index 59c2b851a..004459a0a 100644
--- a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Frames/Http2ContinuationFrameErrorSpec.cs
+++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Frames/Http2ContinuationFrameErrorSpec.cs
@@ -198,8 +198,9 @@ public void Http2FrameDecoder_should_buffer_partial_continuation_when_tcp_fragme
var contBytes = new ContinuationFrame(1, block.AsMemory()[split..], endHeaders: true).Serialize();
var decoder = new FrameDecoder();
- // Feed HEADERS fully.
- var firstBatch = decoder.Decode(headersBytes);
+ // Feed HEADERS fully. Decode returns the decoder's reused list, so snapshot it before the
+ // later Decode calls repopulate it (firstBatch is read again at the Concat below).
+ var firstBatch = decoder.Decode(headersBytes).ToList();
Assert.Single(firstBatch);
// Feed first half of CONTINUATION bytes — incomplete frame: no new frames yet.
diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Frames/Http2DecoderReuseSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Frames/Http2DecoderReuseSpec.cs
new file mode 100644
index 000000000..d249c439c
--- /dev/null
+++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Frames/Http2DecoderReuseSpec.cs
@@ -0,0 +1,73 @@
+using TurboHTTP.Protocol.Syntax.Http2;
+
+namespace TurboHTTP.Tests.Protocol.Syntax.Http2.Frames;
+
+///
+/// returns the decoder's reused frame list (no per-call array
+/// allocation). The client/server state machines consume it synchronously within the same actor
+/// message; a caller that needs to hold a result across calls must snapshot it. These tests pin the
+/// reuse behaviour and guard against leaking a prior call's frames on the early-return path.
+///
+public sealed class Http2DecoderReuseSpec
+{
+ [Fact(Timeout = 5000)]
+ [Trait("RFC", "RFC9113-4.1")]
+ public void Decode_should_decode_multiple_frames_in_order()
+ {
+ var bytes = Concat(
+ new PingFrame(new byte[8], isAck: false).Serialize(),
+ new WindowUpdateFrame(1, 65535).Serialize());
+
+ var frames = new FrameDecoder().Decode(bytes);
+
+ Assert.Equal(2, frames.Count);
+ Assert.IsType(frames[0]);
+ var wu = Assert.IsType(frames[1]);
+ Assert.Equal(65535, wu.Increment);
+ }
+
+ [Fact(Timeout = 5000)]
+ [Trait("RFC", "RFC9113-4.1")]
+ public void Decode_should_return_an_empty_list_for_an_incomplete_frame()
+ {
+ // Fewer than the 9-octet frame header: no complete frame is produced.
+ var frames = new FrameDecoder().Decode(new byte[] { 0, 0, 5 });
+
+ Assert.Empty(frames);
+ }
+
+ [Fact(Timeout = 5000)]
+ [Trait("RFC", "RFC9113-4.1")]
+ public void Decode_should_not_leak_frames_from_a_previous_call_when_nothing_new_decodes()
+ {
+ var decoder = new FrameDecoder();
+
+ var first = decoder.Decode(new PingFrame(new byte[8], isAck: false).Serialize());
+ Assert.Single(first);
+
+ // An empty feed with no buffered remainder must not surface the previous call's frames.
+ var second = decoder.Decode(Array.Empty());
+ Assert.Empty(second);
+ }
+
+ [Fact(Timeout = 5000)]
+ [Trait("RFC", "RFC9113-4.1")]
+ public void Decode_should_reuse_the_same_list_instance_across_calls()
+ {
+ var decoder = new FrameDecoder();
+
+ var first = decoder.Decode(new PingFrame(new byte[8], isAck: false).Serialize());
+ var second = decoder.Decode(new PingFrame(new byte[8], isAck: true).Serialize());
+
+ // No fresh collection is allocated per call — the reused list is returned directly.
+ Assert.Same(first, second);
+ }
+
+ private static byte[] Concat(byte[] a, byte[] b)
+ {
+ var result = new byte[a.Length + b.Length];
+ a.CopyTo(result, 0);
+ b.CopyTo(result, a.Length);
+ return result;
+ }
+}
diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Hpack/HpackDynamicTableSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Hpack/HpackDynamicTableSpec.cs
index f6fd6a30d..5b64f0fb1 100644
--- a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Hpack/HpackDynamicTableSpec.cs
+++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Hpack/HpackDynamicTableSpec.cs
@@ -50,7 +50,7 @@ public void HpackDynamicTable_should_return_null_when_getting_entry_at_index_zer
public void HpackDynamicTable_should_have_correct_size_when_single_entry_is_added()
{
var table = new HpackDynamicTable();
- table.Add("via", "proxy1");
+ table.Add("via", "proxy1", 3, 6);
Assert.Equal(41, table.CurrentSize);
}
@@ -59,8 +59,8 @@ public void HpackDynamicTable_should_have_correct_size_when_single_entry_is_adde
public void HpackDynamicTable_should_accumulate_size_when_two_entries_are_added()
{
var table = new HpackDynamicTable();
- table.Add("via", "proxy1");
- table.Add("age", "100");
+ table.Add("via", "proxy1", 3, 6);
+ table.Add("age", "100", 3, 3);
Assert.Equal(79, table.CurrentSize);
}
@@ -69,7 +69,7 @@ public void HpackDynamicTable_should_accumulate_size_when_two_entries_are_added(
public void HpackDynamicTable_should_add_32_bytes_when_adding_empty_name_and_value()
{
var table = new HpackDynamicTable();
- table.Add(string.Empty, string.Empty);
+ table.Add(string.Empty, string.Empty, 0, 0);
Assert.Equal(32, table.CurrentSize);
}
@@ -79,9 +79,9 @@ public void HpackDynamicTable_should_count_size_as_utf8_bytes_when_name_contains
{
var table = new HpackDynamicTable();
const string name = "café";
- var expected = Encoding.UTF8.GetByteCount(name) + 0 + 32;
- table.Add(name, string.Empty);
- Assert.Equal(expected, table.CurrentSize);
+ var nameBytes = Encoding.UTF8.GetByteCount(name);
+ table.Add(name, string.Empty, nameBytes, 0);
+ Assert.Equal(nameBytes + 0 + 32, table.CurrentSize);
}
[Fact(Timeout = 5000)]
@@ -90,9 +90,9 @@ public void HpackDynamicTable_should_count_size_as_utf8_bytes_when_value_contain
{
var table = new HpackDynamicTable();
const string value = "héllo";
- var expected = 1 + Encoding.UTF8.GetByteCount(value) + 32;
- table.Add("x", value);
- Assert.Equal(expected, table.CurrentSize);
+ var valueBytes = Encoding.UTF8.GetByteCount(value);
+ table.Add("x", value, 1, valueBytes);
+ Assert.Equal(1 + valueBytes + 32, table.CurrentSize);
}
[Fact(Timeout = 5000)]
@@ -100,8 +100,8 @@ public void HpackDynamicTable_should_count_size_as_utf8_bytes_when_value_contain
public void HpackDynamicTable_should_return_most_recent_entry_when_getting_entry_1()
{
var table = new HpackDynamicTable();
- table.Add("first", "v1");
- table.Add("second", "v2");
+ table.Add("first", "v1", 5, 2);
+ table.Add("second", "v2", 6, 2);
var entry = table.GetEntry(1);
Assert.NotNull(entry);
@@ -114,8 +114,8 @@ public void HpackDynamicTable_should_return_most_recent_entry_when_getting_entry
public void HpackDynamicTable_should_return_second_most_recent_entry_when_getting_entry_2()
{
var table = new HpackDynamicTable();
- table.Add("first", "v1");
- table.Add("second", "v2");
+ table.Add("first", "v1", 5, 2);
+ table.Add("second", "v2", 6, 2);
var entry = table.GetEntry(2);
Assert.NotNull(entry);
@@ -128,9 +128,9 @@ public void HpackDynamicTable_should_return_second_most_recent_entry_when_gettin
public void HpackDynamicTable_should_have_oldest_at_highest_index_when_entries_are_added_in_fifo_order()
{
var table = new HpackDynamicTable();
- table.Add("a", "1");
- table.Add("b", "2");
- table.Add("c", "3");
+ table.Add("a", "1", 1, 1);
+ table.Add("b", "2", 1, 1);
+ table.Add("c", "3", 1, 1);
Assert.Equal("c", table.GetEntry(1)!.Value.Name);
Assert.Equal("b", table.GetEntry(2)!.Value.Name);
@@ -143,7 +143,7 @@ public void HpackDynamicTable_should_have_oldest_at_highest_index_when_entries_a
public void HpackDynamicTable_should_return_null_when_getting_entry_beyond_count()
{
var table = new HpackDynamicTable();
- table.Add("x", "y");
+ table.Add("x", "y", 1, 1);
Assert.Null(table.GetEntry(2));
Assert.Null(table.GetEntry(99));
}
@@ -153,9 +153,9 @@ public void HpackDynamicTable_should_return_null_when_getting_entry_beyond_count
public void HpackDynamicTable_should_remove_oldest_entry_first_when_eviction_occurs()
{
var table = new HpackDynamicTable();
- table.Add("alpha", "1");
- table.Add("beta", "2");
- table.Add("gamma", "3");
+ table.Add("alpha", "1", 5, 1);
+ table.Add("beta", "2", 4, 1);
+ table.Add("gamma", "3", 5, 1);
var gammaSize = "gamma".Length + "3".Length + 32;
var betaSize = "beta".Length + "2".Length + 32;
@@ -173,12 +173,12 @@ public void HpackDynamicTable_should_remove_oldest_entry_first_when_eviction_occ
public void HpackDynamicTable_should_clear_table_when_adding_oversized_entry()
{
var table = new HpackDynamicTable();
- table.Add("x", "y");
+ table.Add("x", "y", 1, 1);
Assert.Equal(1, table.Count);
table.SetMaxSize(10);
- table.Add("longname", "longvalue");
+ table.Add("longname", "longvalue", 8, 9);
Assert.Equal(0, table.Count);
Assert.Equal(0, table.CurrentSize);
@@ -189,8 +189,8 @@ public void HpackDynamicTable_should_clear_table_when_adding_oversized_entry()
public void HpackDynamicTable_should_evict_all_entries_when_max_size_is_set_to_zero()
{
var table = new HpackDynamicTable();
- table.Add("x", "y");
- table.Add("a", "b");
+ table.Add("x", "y", 1, 1);
+ table.Add("a", "b", 1, 1);
table.SetMaxSize(0);
Assert.Equal(0, table.Count);
Assert.Equal(0, table.CurrentSize);
@@ -203,13 +203,13 @@ public void HpackDynamicTable_should_evict_oldest_to_fit_when_adding_to_full_tab
var table = new HpackDynamicTable();
table.SetMaxSize(68);
- table.Add("k", "1");
- table.Add("k", "2");
+ table.Add("k", "1", 1, 1);
+ table.Add("k", "2", 1, 1);
Assert.Equal(2, table.Count);
Assert.Equal(68, table.CurrentSize);
- table.Add("k", "3");
+ table.Add("k", "3", 1, 1);
Assert.Equal(2, table.Count);
Assert.Equal(68, table.CurrentSize);
Assert.Equal("3", table.GetEntry(1)!.Value.Value);
@@ -225,12 +225,13 @@ public void HpackDynamicTable_should_evict_multiple_old_entries_when_new_entry_r
for (var i = 0; i < 5; i++)
{
- table.Add("h", i.ToString());
+ var value = i.ToString();
+ table.Add("h", value, 1, value.Length);
}
Assert.Equal(5, table.Count);
- table.Add("bigname", "bigvalue");
+ table.Add("bigname", "bigvalue", 7, 8);
Assert.Equal(47, table.GetEntry(1)!.Value.Name.Length + table.GetEntry(1)!.Value.Value.Length + 32);
Assert.True(table.CurrentSize <= 200);
@@ -250,7 +251,7 @@ public void HpackDynamicTable_should_update_max_size_when_set_max_size_is_called
public void HpackDynamicTable_should_not_change_entries_when_set_max_size_called_with_same_value()
{
var table = new HpackDynamicTable();
- table.Add("x", "y");
+ table.Add("x", "y", 1, 1);
var sizeBefore = table.CurrentSize;
table.SetMaxSize(4096);
Assert.Equal(sizeBefore, table.CurrentSize);
@@ -270,7 +271,7 @@ public void HpackDynamicTable_should_throw_hpackexception_when_set_max_size_is_n
public void HpackDynamicTable_should_keep_entry_when_max_size_set_to_exact_entry_size()
{
var table = new HpackDynamicTable();
- table.Add("via", "proxy");
+ table.Add("via", "proxy", 3, 5);
table.SetMaxSize(40);
Assert.Equal(1, table.Count);
Assert.Equal(40, table.CurrentSize);
@@ -281,7 +282,7 @@ public void HpackDynamicTable_should_keep_entry_when_max_size_set_to_exact_entry
public void HpackDynamicTable_should_evict_entry_when_max_size_set_to_one_less_than_entry_size()
{
var table = new HpackDynamicTable();
- table.Add("via", "proxy");
+ table.Add("via", "proxy", 3, 5);
table.SetMaxSize(39);
Assert.Equal(0, table.Count);
Assert.Equal(0, table.CurrentSize);
@@ -293,8 +294,8 @@ public void HpackDynamicTable_should_not_evict_when_table_fills_exactly_to_max_s
{
var table = new HpackDynamicTable();
table.SetMaxSize(68);
- table.Add("k", "1");
- table.Add("k", "2");
+ table.Add("k", "1", 1, 1);
+ table.Add("k", "2", 1, 1);
Assert.Equal(2, table.Count);
Assert.Equal(68, table.CurrentSize);
}
@@ -305,8 +306,8 @@ public void HpackDynamicTable_should_evict_oldest_when_one_byte_beyond_max_size_
{
var table = new HpackDynamicTable();
table.SetMaxSize(67);
- table.Add("k", "1");
- table.Add("k", "2");
+ table.Add("k", "1", 1, 1);
+ table.Add("k", "2", 1, 1);
Assert.Equal(1, table.Count);
Assert.Equal("2", table.GetEntry(1)!.Value.Value);
}
@@ -320,7 +321,8 @@ public void HpackDynamicTable_should_keep_size_within_max_size_when_high_volume_
for (var i = 0; i < 100; i++)
{
- table.Add("h", i.ToString());
+ var value = i.ToString();
+ table.Add("h", value, 1, value.Length);
}
Assert.True(table.CurrentSize <= 200, $"CurrentSize {table.CurrentSize} exceeds MaxSize 200");
@@ -331,12 +333,12 @@ public void HpackDynamicTable_should_keep_size_within_max_size_when_high_volume_
public void HpackDynamicTable_should_allow_new_entries_when_table_is_cleared_and_resized()
{
var table = new HpackDynamicTable();
- table.Add("x", "y");
+ table.Add("x", "y", 1, 1);
table.SetMaxSize(0);
Assert.Equal(0, table.Count);
table.SetMaxSize(4096);
- table.Add("new", "entry");
+ table.Add("new", "entry", 3, 5);
Assert.Equal(1, table.Count);
Assert.Equal("new", table.GetEntry(1)!.Value.Name);
}
@@ -346,7 +348,34 @@ public void HpackDynamicTable_should_allow_new_entries_when_table_is_cleared_and
public void HpackDynamicTable_should_return_null_when_getting_entry_at_negative_index()
{
var table = new HpackDynamicTable();
- table.Add("x", "y");
+ table.Add("x", "y", 1, 1);
Assert.Null(table.GetEntry(-1));
}
-}
\ No newline at end of file
+
+ [Fact(Timeout = 5000)]
+ [Trait("RFC", "RFC7541-4")]
+ public void HpackDynamicTable_should_size_entry_by_provided_byte_lengths()
+ {
+ // The encoder/decoder compute the raw UTF-8 byte lengths while (de)serializing the header,
+ // so Add reuses them for entry sizing instead of recomputing GetByteCount:
+ // entrySize = name + value + 32.
+ var table = new HpackDynamicTable();
+ table.Add("via", "proxy1", nameByteLength: 3, valueByteLength: 6);
+ Assert.Equal(3 + 6 + 32, table.CurrentSize);
+ }
+
+ [Fact(Timeout = 5000)]
+ [Trait("RFC", "RFC7541-4")]
+ public void HpackDynamicTable_should_size_multibyte_entry_by_provided_utf8_byte_lengths()
+ {
+ const string name = "café";
+ const string value = "héllo";
+ var nameLen = Encoding.UTF8.GetByteCount(name);
+ var valueLen = Encoding.UTF8.GetByteCount(value);
+
+ var table = new HpackDynamicTable();
+ table.Add(name, value, nameLen, valueLen);
+
+ Assert.Equal(nameLen + valueLen + 32, table.CurrentSize);
+ }
+}
diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Hpack/HpackTableRepresentationSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Hpack/HpackTableRepresentationSpec.cs
index f24c1baf4..d4643e2e7 100644
--- a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Hpack/HpackTableRepresentationSpec.cs
+++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Hpack/HpackTableRepresentationSpec.cs
@@ -11,9 +11,9 @@ public void HpackTableRepresentation_should_evict_oldest_entry_when_exceeding_si
var table = new HpackDynamicTable();
table.SetMaxSize(100);
- table.Add("name1", "value1");
- table.Add("name2", "value2");
- table.Add("name3", "value3");
+ table.Add("name1", "value1", 5, 6);
+ table.Add("name2", "value2", 5, 6);
+ table.Add("name3", "value3", 5, 6);
Assert.True(table.CurrentSize <= 100);
}
@@ -117,9 +117,9 @@ public void HpackTableRepresentation_should_encode_proxy_auth_as_never_indexed()
public void HpackTableRepresentation_should_preserve_entry_order_after_size_update()
{
var table = new HpackDynamicTable();
- table.Add("a", "1");
- table.Add("b", "2");
- table.Add("c", "3");
+ table.Add("a", "1", 1, 1);
+ table.Add("b", "2", 1, 1);
+ table.Add("c", "3", 1, 1);
table.SetMaxSize(1024);
diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Http2AdaptiveWindowScalingRegressionSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Http2AdaptiveWindowScalingRegressionSpec.cs
index 79ac60863..58dc62126 100644
--- a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Http2AdaptiveWindowScalingRegressionSpec.cs
+++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Http2AdaptiveWindowScalingRegressionSpec.cs
@@ -117,4 +117,57 @@ public void Disabled_scaling_should_keep_a_fixed_window_under_identical_load()
Assert.Equal(Start, fc.CurrentStreamWindow);
}
}
+
+ [Fact(Timeout = 5000)]
+ [Trait("RFC", "RFC9113-6.9")]
+ public void A_new_stream_after_window_scaling_must_be_replenished_within_the_advertised_initial_window()
+ {
+ // Fast, deterministic UNIT repro for the H2 single-connection large-download deadlock — the
+ // mechanism behind the resource-heavy integration repro
+ // (TurboHTTP.IntegrationTests.Client/H2/LargeDownloadRegressionSpec).
+ //
+ // The adaptive scaler grows the GLOBAL per-stream WINDOW_UPDATE threshold, but a freshly opened
+ // stream's *server* send window is still the advertised SETTINGS_INITIAL_WINDOW_SIZE (Start) —
+ // we never re-advertise a larger one. If the threshold grew past Start, the new stream's server
+ // window is exhausted before the client ever accumulates enough to emit a WINDOW_UPDATE, so the
+ // stream deadlocks. No server, no sockets, no concurrency needed to pin it.
+ var clock = new FakeTimeProvider();
+ var fc = NewScaling(clock);
+ EstablishMinRtt(fc, clock, 100);
+
+ // Ratchet the adaptive window (and, before the fix, the shared WU threshold) far above Start.
+ for (var round = 0; round < 12; round++)
+ {
+ var window = fc.CurrentStreamWindow;
+ fc.OnInboundData(1, window / 2);
+ clock.Advance(TimeSpan.FromMilliseconds(10));
+ fc.OnInboundData(1, window - window / 2);
+ }
+
+ Assert.True(fc.CurrentStreamWindow >= Start * 4,
+ "precondition: the scaler must have grown the window well past the advertised initial");
+
+ // A brand-new stream. Its server send window is the advertised initial (Start), NOT the scaled
+ // window. Deliver up to the advertised window in small chunks: a WINDOW_UPDATE MUST be emitted
+ // before the advertised window is consumed, or the server stalls and the stream deadlocks.
+ const int newStream = 3;
+ const int chunk = 16 * 1024;
+ var consumed = 0;
+ var emittedWindowUpdate = false;
+ while (consumed < Start)
+ {
+ var result = fc.OnInboundData(newStream, chunk);
+ Assert.True(result.Success, "delivering within the advertised window must never violate flow control");
+ consumed += chunk;
+ if (result.StreamWindowUpdate is not null)
+ {
+ emittedWindowUpdate = true;
+ break;
+ }
+ }
+
+ Assert.True(emittedWindowUpdate,
+ $"a new stream must receive a WINDOW_UPDATE within its advertised window ({Start} bytes); " +
+ "otherwise the server send window is exhausted before replenishment and the stream deadlocks.");
+ }
}
diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Security/HpackBombSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Security/HpackBombSpec.cs
index fa50dd9bd..21316b3e8 100644
--- a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Security/HpackBombSpec.cs
+++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Security/HpackBombSpec.cs
@@ -74,7 +74,9 @@ public void HpackDynamicTable_should_evict_all_entries_when_table_size_set_to_ze
// Fill the table
for (var i = 0; i < 50; i++)
{
- table.Add($"header-{i}", $"value-{i}");
+ var name = $"header-{i}";
+ var value = $"value-{i}";
+ table.Add(name, value, name.Length, value.Length);
}
Assert.True(table.Count > 0);
@@ -317,7 +319,8 @@ public void HpackDynamicTable_should_correctly_evict_when_more_than_100_entries_
// 4096 / 35 ≈ 117 entries max
for (var i = 0; i < 200; i++)
{
- table.Add($"h{i}", "v");
+ var name = $"h{i}";
+ table.Add(name, "v", name.Length, 1);
}
// Table should never exceed max size
@@ -347,7 +350,9 @@ public void HpackDynamicTable_should_not_grow_memory_when_rapid_fill_evict_cycle
// Fill with entries
for (var i = 0; i < 30; i++)
{
- table.Add($"c{cycle}-h{i}", new string('x', 20));
+ var name = $"c{cycle}-h{i}";
+ var value = new string('x', 20);
+ table.Add(name, value, name.Length, value.Length);
}
// Reset to zero
@@ -372,11 +377,12 @@ public void HpackDynamicTable_should_clear_table_without_inserting_when_entry_si
table.SetMaxSize(64); // Very small table
// Add a normal entry first
- table.Add("a", "b"); // 1 + 1 + 32 = 34 bytes
+ table.Add("a", "b", 1, 1); // 1 + 1 + 32 = 34 bytes
Assert.Equal(1, table.Count);
// Add an oversized entry: name(1) + value(100) + 32 = 133 > 64
- table.Add("x", new string('Z', 100));
+ var oversized = new string('Z', 100);
+ table.Add("x", oversized, 1, oversized.Length);
// Table should be cleared and oversized entry NOT added
Assert.Equal(0, table.Count);
diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Streaming/Http2ServerBatchedDataEmissionSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Streaming/Http2ServerBatchedDataEmissionSpec.cs
new file mode 100644
index 000000000..70920843a
--- /dev/null
+++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http2/Server/Streaming/Http2ServerBatchedDataEmissionSpec.cs
@@ -0,0 +1,288 @@
+using Microsoft.AspNetCore.Http.Features;
+using Servus.Akka.Transport;
+using TurboHTTP.Protocol.Syntax.Http2;
+using TurboHTTP.Protocol.Syntax.Http2.Hpack;
+using TurboHTTP.Protocol.Syntax.Http2.Server;
+using TurboHTTP.Server;
+using TurboHTTP.Tests.Shared;
+
+namespace TurboHTTP.Tests.Protocol.Syntax.Http2.Server.Streaming;
+
+public sealed class Http2ServerBatchedDataEmissionSpec
+{
+ private static byte[] BuildHeadersFrame(int streamId, ReadOnlyMemory headerBlock, bool endStream = false,
+ bool endHeaders = true)
+ {
+ const int frameHeaderSize = 9;
+ var frameSize = frameHeaderSize + headerBlock.Length;
+ var frame = new byte[frameSize];
+
+ var length = headerBlock.Length;
+ frame[0] = (byte)(length >> 16);
+ frame[1] = (byte)(length >> 8);
+ frame[2] = (byte)length;
+ frame[3] = (byte)FrameType.Headers;
+
+ byte flags = 0;
+ if (endStream) flags |= (byte)Headers.EndStream;
+ if (endHeaders) flags |= (byte)Headers.EndHeaders;
+ frame[4] = flags;
+
+ frame[5] = (byte)(streamId >> 24);
+ frame[6] = (byte)(streamId >> 16);
+ frame[7] = (byte)(streamId >> 8);
+ frame[8] = (byte)streamId;
+
+ headerBlock.Span.CopyTo(frame.AsSpan(frameHeaderSize));
+
+ return frame;
+ }
+
+ private static byte[] BuildSettingsFrameWithMaxFrameSize(uint maxFrameSize)
+ {
+ const int frameHeaderSize = 9;
+ const int paramSize = 6;
+ var frame = new byte[frameHeaderSize + paramSize];
+
+ frame[0] = 0;
+ frame[1] = 0;
+ frame[2] = paramSize;
+ frame[3] = (byte)FrameType.Settings;
+ frame[4] = 0;
+
+ var key = (ushort)SettingsParameter.MaxFrameSize;
+ frame[9] = (byte)(key >> 8);
+ frame[10] = (byte)key;
+ frame[11] = (byte)(maxFrameSize >> 24);
+ frame[12] = (byte)(maxFrameSize >> 16);
+ frame[13] = (byte)(maxFrameSize >> 8);
+ frame[14] = (byte)maxFrameSize;
+
+ return frame;
+ }
+
+ private static byte[] BuildWindowUpdateFrame(int streamId, uint increment)
+ {
+ const int frameHeaderSize = 9;
+ const int windowUpdateSize = 4;
+ var frame = new byte[frameHeaderSize + windowUpdateSize];
+
+ frame[0] = 0;
+ frame[1] = 0;
+ frame[2] = windowUpdateSize;
+ frame[3] = (byte)FrameType.WindowUpdate;
+
+ frame[5] = (byte)(streamId >> 24);
+ frame[6] = (byte)(streamId >> 16);
+ frame[7] = (byte)(streamId >> 8);
+ frame[8] = (byte)streamId;
+
+ var incValue = increment & 0x7FFFFFFF;
+ frame[9] = (byte)(incValue >> 24);
+ frame[10] = (byte)(incValue >> 16);
+ frame[11] = (byte)(incValue >> 8);
+ frame[12] = (byte)incValue;
+
+ return frame;
+ }
+
+ private static ReadOnlyMemory EncodeHeaders(string method, string path, string authority = "localhost")
+ {
+ var encoder = new HpackEncoder(useHuffman: true);
+ var headers = new List
+ {
+ new(":method", method),
+ new(":path", path),
+ new(":scheme", "https"),
+ new(":authority", authority),
+ };
+
+ var buffer = new byte[4096];
+ var span = buffer.AsSpan();
+ var written = encoder.Encode(headers, ref span, useHuffman: true);
+
+ return new Memory(buffer, 0, written);
+ }
+
+ private static void DecodeFramesAsStream(Http2ServerStateMachine sm, byte[] frameData)
+ {
+ var buffer = TransportBuffer.Rent(frameData.Length);
+ frameData.CopyTo(buffer.FullMemory.Span);
+ buffer.Length = frameData.Length;
+ sm.DecodeClientData(TransportData.Rent(buffer));
+ }
+
+ private sealed record FrameExtractionResult(
+ List AllFrames,
+ List DataFrames,
+ int DataCarryingBufferCount);
+
+ private static FrameExtractionResult ExtractFramesAndCountBuffers(
+ List outbound, int startIndex = 0)
+ {
+ var allFrames = new List();
+ var dataFrames = new List();
+ var dataBufferCount = 0;
+ var decoder = new FrameDecoder();
+
+ for (var i = startIndex; i < outbound.Count; i++)
+ {
+ if (outbound[i] is TransportData td)
+ {
+ var decoded = decoder.Decode(td.Buffer);
+ var hasData = false;
+ foreach (var frame in decoded)
+ {
+ allFrames.Add(frame);
+ if (frame is DataFrame df)
+ {
+ dataFrames.Add(df);
+ hasData = true;
+ }
+ }
+
+ if (hasData)
+ {
+ dataBufferCount++;
+ }
+ }
+ }
+
+ return new FrameExtractionResult(allFrames, dataFrames, dataBufferCount);
+ }
+
+ private static Http2ServerStateMachine CreateSmWithClientMaxFrameSize(
+ FakeServerOps ops, uint clientMaxFrameSize, int connectionWindow = 1024 * 1024)
+ {
+ var sm = new Http2ServerStateMachine(new TurboServerOptions().ToHttp2Options(), ops);
+ sm.PreStart();
+
+ var settingsFrame = BuildSettingsFrameWithMaxFrameSize(clientMaxFrameSize);
+ DecodeFramesAsStream(sm, settingsFrame);
+
+ if (connectionWindow > 65535)
+ {
+ var connWindowUpdate = BuildWindowUpdateFrame(0, (uint)(connectionWindow - 65535));
+ DecodeFramesAsStream(sm, connWindowUpdate);
+ }
+
+ ops.Outbound.Clear();
+ return sm;
+ }
+
+ private static IFeatureCollection SendGetAndWriteBufferedBody(
+ Http2ServerStateMachine sm, FakeServerOps ops, int streamId, int bodySize)
+ {
+ var headerBlock = EncodeHeaders("GET", "/large", "example.com");
+ var headersFrameData = BuildHeadersFrame(streamId, headerBlock, endStream: true, endHeaders: true);
+ DecodeFramesAsStream(sm, headersFrameData);
+
+ var features = ops.Requests[^1];
+ var responseFeature = features.Get()!;
+ responseFeature.StatusCode = 200;
+ responseFeature.Headers["Content-Length"] = bodySize.ToString();
+
+ var bodyFeature = features.Get()!;
+ var body = new byte[bodySize];
+ for (var i = 0; i < body.Length; i++)
+ {
+ body[i] = (byte)(i % 251);
+ }
+
+ var writer = bodyFeature.Writer;
+ var mem = writer.GetMemory(bodySize);
+ body.CopyTo(mem);
+ writer.Advance(bodySize);
+ writer.Complete();
+
+ return features;
+ }
+
+ [Fact(Timeout = 5000)]
+ public void OnResponse_buffered_body_should_batch_data_frames_into_fewer_buffers()
+ {
+ var ops = new FakeServerOps();
+ const uint clientMaxFrameSize = 16 * 1024;
+ const int bodySize = 48 * 1024;
+ var sm = CreateSmWithClientMaxFrameSize(ops, clientMaxFrameSize, connectionWindow: bodySize + 65535);
+
+ var features = SendGetAndWriteBufferedBody(sm, ops, streamId: 1, bodySize);
+ var streamWindowUpdate = BuildWindowUpdateFrame(1, (uint)bodySize);
+ DecodeFramesAsStream(sm, streamWindowUpdate);
+
+ ops.Outbound.Clear();
+ sm.OnResponse(features);
+
+ var result = ExtractFramesAndCountBuffers(ops.Outbound);
+
+ Assert.Equal(3, result.DataFrames.Count);
+
+ var totalDataBytes = result.DataFrames.Sum(df => df.Data.Length);
+ Assert.Equal(bodySize, totalDataBytes);
+
+ Assert.True(result.DataFrames[^1].EndStream);
+ for (var i = 0; i < result.DataFrames.Count - 1; i++)
+ {
+ Assert.False(result.DataFrames[i].EndStream);
+ }
+
+ Assert.Equal(1, result.DataCarryingBufferCount);
+ }
+
+ [Fact(Timeout = 5000)]
+ public void OnResponse_single_frame_body_should_emit_one_buffer()
+ {
+ var ops = new FakeServerOps();
+ const uint clientMaxFrameSize = 16 * 1024;
+ const int bodySize = 2 * 1024;
+ var sm = CreateSmWithClientMaxFrameSize(ops, clientMaxFrameSize, connectionWindow: bodySize + 65535);
+
+ var features = SendGetAndWriteBufferedBody(sm, ops, streamId: 1, bodySize);
+ var streamWindowUpdate = BuildWindowUpdateFrame(1, (uint)bodySize);
+ DecodeFramesAsStream(sm, streamWindowUpdate);
+
+ ops.Outbound.Clear();
+ sm.OnResponse(features);
+
+ var result = ExtractFramesAndCountBuffers(ops.Outbound);
+
+ Assert.Single(result.DataFrames);
+ Assert.Equal(bodySize, result.DataFrames[0].Data.Length);
+ Assert.True(result.DataFrames[0].EndStream);
+ Assert.Equal(1, result.DataCarryingBufferCount);
+ }
+
+ [Fact(Timeout = 5000)]
+ public void OnResponse_large_body_should_batch_many_frames_into_fewer_buffers()
+ {
+ var ops = new FakeServerOps();
+ const uint clientMaxFrameSize = 16 * 1024;
+ const int bodySize = 128 * 1024;
+ var sm = CreateSmWithClientMaxFrameSize(ops, clientMaxFrameSize, connectionWindow: bodySize + 65535);
+
+ var features = SendGetAndWriteBufferedBody(sm, ops, streamId: 1, bodySize);
+ var streamWindowUpdate = BuildWindowUpdateFrame(1, (uint)bodySize);
+ DecodeFramesAsStream(sm, streamWindowUpdate);
+
+ ops.Outbound.Clear();
+ sm.OnResponse(features);
+
+ var result = ExtractFramesAndCountBuffers(ops.Outbound);
+
+ Assert.Equal(8, result.DataFrames.Count);
+
+ var offset = 0;
+ foreach (var df in result.DataFrames)
+ {
+ for (var i = 0; i < df.Data.Length; i++)
+ {
+ Assert.Equal((byte)((offset + i) % 251), df.Data.Span[i]);
+ }
+
+ offset += df.Data.Length;
+ }
+
+ Assert.Equal(bodySize, offset);
+ Assert.Equal(1, result.DataCarryingBufferCount);
+ }
+}
diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/StateMachine/Http3ClientConnectionErrorSpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/StateMachine/Http3ClientConnectionErrorSpec.cs
index dcf3cf904..dda577203 100644
--- a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/StateMachine/Http3ClientConnectionErrorSpec.cs
+++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Client/StateMachine/Http3ClientConnectionErrorSpec.cs
@@ -28,6 +28,45 @@ private static TransportBuffer SerializeFrame(Http3Frame frame)
return buffer;
}
+ [Fact(Timeout = 5000)]
+ [Trait("RFC", "RFC9114-5.2")]
+ public void Repeated_stream_error_should_trigger_exactly_one_reconnect()
+ {
+ // QUIC reports a single connection failure as a StreamClosed(Error) PER stream (plus a trailing
+ // TransportDisconnected). Each routes to OnConnectionLost; only the FIRST may start the reconnect.
+ // Without idempotency the second call re-buffers an already-drained (empty) correlation map —
+ // wiping the replay set — and emits a duplicate ConnectTransport.
+ var sm = CreateMachine();
+ sm.OnRequest(new HttpRequestMessage(HttpMethod.Get, "https://example.com/") { Version = new Version(3, 0) });
+ _clientOps.Outbound.Clear();
+
+ sm.DecodeServerData(new StreamClosed(0, DisconnectReason.Error));
+ sm.DecodeServerData(new StreamClosed(4, DisconnectReason.Error));
+
+ Assert.True(sm.IsReconnecting);
+ Assert.Single(_clientOps.Outbound, o => o is ConnectTransport);
+ }
+
+ [Fact(Timeout = 5000)]
+ [Trait("RFC", "RFC9114-5.2")]
+ public void Connection_failure_as_stream_errors_then_disconnect_should_reconnect_once()
+ {
+ // The full QUIC connection-failure signal pattern that an AutoReconnect=false (TCP-style) transport
+ // emits: StreamClosed(Error) per stream, then a trailing TransportDisconnected. The state machine
+ // must coalesce all of it into exactly ONE reconnect — the trailing disconnect is the same failure,
+ // not a failed reconnect attempt (which would emit a second ConnectTransport / burn an attempt).
+ var sm = CreateMachine();
+ sm.OnRequest(new HttpRequestMessage(HttpMethod.Get, "https://example.com/") { Version = new Version(3, 0) });
+ _clientOps.Outbound.Clear();
+
+ sm.DecodeServerData(new StreamClosed(0, DisconnectReason.Error));
+ sm.DecodeServerData(new StreamClosed(4, DisconnectReason.Error));
+ sm.DecodeServerData(new TransportDisconnected(DisconnectReason.Error));
+
+ Assert.True(sm.IsReconnecting);
+ Assert.Single(_clientOps.Outbound, o => o is ConnectTransport);
+ }
+
[Fact(Timeout = 5000)]
[Trait("RFC", "RFC9114-7.2.4")]
public void Second_settings_frame_on_control_stream_should_disconnect()
diff --git a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Security/QpackSecuritySpec.cs b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Security/QpackSecuritySpec.cs
index c29261b55..d31dfb85b 100644
--- a/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Security/QpackSecuritySpec.cs
+++ b/src/TurboHTTP.Tests/Protocol/Syntax/Http3/Security/QpackSecuritySpec.cs
@@ -228,7 +228,9 @@ public void HpackDynamicTable_should_never_exceed_max_size_after_1000_inserts()
for (var i = 0; i < 1000; i++)
{
- table.Add($"header-{i}", new string('x', i % 100));
+ var name = $"header-{i}";
+ var value = new string('x', i % 100);
+ table.Add(name, value, name.Length, value.Length);
Assert.True(table.CurrentSize <= maxSize,
$"HPACK table size {table.CurrentSize} exceeded max {maxSize} at insert {i}");
diff --git a/src/TurboHTTP.Tests/Protocol/WellKnownHeadersSpec.cs b/src/TurboHTTP.Tests/Protocol/WellKnownHeadersSpec.cs
index 631158210..afec0f351 100644
--- a/src/TurboHTTP.Tests/Protocol/WellKnownHeadersSpec.cs
+++ b/src/TurboHTTP.Tests/Protocol/WellKnownHeadersSpec.cs
@@ -268,6 +268,28 @@ public void GetOrCreateHeaderName_should_handle_empty_span()
Assert.Equal("", result.Name);
}
+ [Fact(Timeout = 5000)]
+ public void GetOrCreateHeaderNameStringIgnoreCase_should_return_the_cached_interned_name_for_well_known()
+ {
+ // Well-known names resolve to the cached header's interned Name string with no per-call
+ // allocation (and no wasted ASCII byte[] copy) — Assert.Same proves the same instance.
+ Assert.Same(WellKnownHeaders.ContentLength.Name,
+ WellKnownHeaders.GetOrCreateHeaderNameStringIgnoreCase("content-length"u8));
+ }
+
+ [Fact(Timeout = 5000)]
+ public void GetOrCreateHeaderNameStringIgnoreCase_should_decode_unknown_names()
+ {
+ Assert.Equal("X-Custom-Thing",
+ WellKnownHeaders.GetOrCreateHeaderNameStringIgnoreCase("X-Custom-Thing"u8));
+ }
+
+ [Fact(Timeout = 5000)]
+ public void GetOrCreateHeaderNameStringIgnoreCase_should_handle_empty_span()
+ {
+ Assert.Equal("", WellKnownHeaders.GetOrCreateHeaderNameStringIgnoreCase([]));
+ }
+
[Fact(Timeout = 5000)]
public void EqualsIgnoreCase_should_match_same_case()
{
diff --git a/src/TurboHTTP.Tests/Streams/Stages/Lifecycle/ConsumerIngressFailureIsolationSpec.cs b/src/TurboHTTP.Tests/Streams/Stages/Lifecycle/ConsumerIngressFailureIsolationSpec.cs
new file mode 100644
index 000000000..997f884fc
--- /dev/null
+++ b/src/TurboHTTP.Tests/Streams/Stages/Lifecycle/ConsumerIngressFailureIsolationSpec.cs
@@ -0,0 +1,147 @@
+using System.Net;
+using System.Threading.Channels;
+using Akka.Streams.Dsl;
+using TurboHTTP.Client;
+using TurboHTTP.Internal;
+using TurboHTTP.Streams.Lifecycle;
+using TurboHTTP.Tests.Shared;
+
+namespace TurboHTTP.Tests.Streams.Stages.Lifecycle;
+
+///
+/// Repro for the high-concurrency client collapse observed in the 2026-06-19 benchmark run
+/// (KestrelTurboSendAsyncConcurrentBenchmarks at ConcurrencyLevel 4096, HTTP/2 + HTTP/3 → "NA",
+/// 1229 exceptions, then a 120s WaitAsync timeout).
+///
+/// Root cause, from the benchmark log stack trace:
+/// System.ObjectDisposedException: Cannot access a disposed object.
+/// Object name: 'System.Net.Http.HttpRequestMessage'.
+/// at System.Net.Http.HttpRequestMessage.set_Version(Version value)
+/// at RequestEnricher.Enrich(...) RequestEnricher.cs:40
+/// at Consumer.<MaterializeIngress>b__0(...) Consumer.cs:97
+/// at Select`2.Logic.OnPush()
+/// at MergeHub`1.HubSink.SinkLogic.OnUpstreamFailure(Exception e)
+///
+/// Under load a request is cancelled (timeout) while still queued in the consumer's ingress
+/// channel; the caller's `using` then disposes the HttpRequestMessage. The ingress later pulls
+/// the now-disposed message through , whose
+/// `request.Version = options.DefaultRequestVersion` throws ObjectDisposedException. Because the
+/// enrichment runs as a bare Select feeding the SHARED , that single
+/// failure tears the consumer's producer off the hub ("removing from MergeHub now") and PERMANENTLY
+/// kills request flow for the whole client — every other in-flight and future request is stranded.
+///
+/// This spec reproduces the defect deterministically at the stage level: one disposed request must
+/// not strand sibling requests on the same consumer. It FAILS (sibling times out) until the ingress
+/// enrichment is made failure-isolating (catch per element, complete that request's pending with the
+/// error, drop it from the stream — never fail the Select / MergeHub producer).
+///
+public sealed class ConsumerIngressFailureIsolationSpec : StreamTestBase
+{
+ // DefaultRequestVersion = 2.0 is what makes RequestEnricher Rule 2 execute `request.Version = ...`
+ // (set_Version), the exact call that throws on the disposed message in the benchmark.
+ private static TurboRequestOptions Options() => new(
+ BaseAddress: new Uri("https://test.example"),
+ DefaultRequestHeaders: new HttpRequestMessage().Headers,
+ DefaultRequestVersion: HttpVersion.Version20,
+ DefaultVersionPolicy: HttpVersionPolicy.RequestVersionOrLower,
+ Timeout: TimeSpan.FromSeconds(30),
+ Credentials: null,
+ PreAuthenticate: false);
+
+ [Fact(Timeout = 15_000)]
+ public async Task Consumer_ingress_should_isolate_a_disposed_request_and_keep_serving_siblings()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ var consumerId = Guid.NewGuid();
+ var requestChannel = Channel.CreateUnbounded();
+ var responseChannel = Channel.CreateUnbounded();
+
+ var (mergeHubSink, broadcastHubSource) = CreateTestHubs();
+
+ var actor = Sys.ActorOf(Consumer.Props(
+ consumerId,
+ requestChannel.Reader,
+ Options,
+ responseChannel.Writer,
+ mergeHubSink,
+ broadcastHubSource,
+ Materializer));
+
+ // 1) Baseline: a normal request flows end-to-end, proving the harness is healthy.
+ var baseline = await RoundTripAsync(requestChannel.Writer, "https://test.example/baseline", ct);
+ Assert.Equal(HttpStatusCode.OK, baseline.StatusCode);
+
+ // 2) Poison: a request whose HttpRequestMessage has already been disposed by the caller
+ // (exactly what `using var request` does after a cancelled SendAsync). Enrich's
+ // `request.Version = 2.0` throws ObjectDisposedException inside the ingress Select.
+ var poison = new HttpRequestMessage(HttpMethod.Get, "https://test.example/poison");
+ poison.Dispose();
+ await requestChannel.Writer.WriteAsync(poison, ct);
+
+ // 3) Sibling: a perfectly valid request enqueued after the poison. It MUST still be served.
+ // With the bug, step 2 has already torn this consumer's producer off the shared MergeHub,
+ // so the sibling is never consumed and its pending never completes.
+ var pending = PendingRequest.Rent();
+ try
+ {
+ var responseTask = pending.GetValueTask();
+ var sibling = new HttpRequestMessage(HttpMethod.Get, "https://test.example/sibling");
+ sibling.Options.Set(OptionsKey.Key, pending);
+ sibling.Options.Set(OptionsKey.VersionKey, pending.Version);
+ await requestChannel.Writer.WriteAsync(sibling, ct);
+
+ HttpResponseMessage siblingResponse;
+ try
+ {
+ siblingResponse = await responseTask.AsTask().WaitAsync(TimeSpan.FromSeconds(3), ct);
+ }
+ catch (TimeoutException)
+ {
+ Assert.Fail(
+ "REPRO: a single disposed request failed the ingress Select and tore the consumer's " +
+ "producer off the shared MergeHub, stranding the sibling request. The per-request " +
+ "enrichment must be failure-isolated so one bad request never bricks the client.");
+ return;
+ }
+
+ Assert.Equal(HttpStatusCode.OK, siblingResponse.StatusCode);
+ Assert.Same(sibling, siblingResponse.RequestMessage);
+ }
+ finally
+ {
+ PendingRequest.Return(pending);
+ Sys.Stop(actor);
+ }
+ }
+
+ private async Task RoundTripAsync(
+ ChannelWriter writer, string uri, CancellationToken ct)
+ {
+ var pending = PendingRequest.Rent();
+ try
+ {
+ var responseTask = pending.GetValueTask();
+ var request = new HttpRequestMessage(HttpMethod.Get, uri);
+ request.Options.Set(OptionsKey.Key, pending);
+ request.Options.Set(OptionsKey.VersionKey, pending.Version);
+ await writer.WriteAsync(request, ct);
+ return await responseTask.AsTask().WaitAsync(TimeSpan.FromSeconds(3), ct);
+ }
+ finally
+ {
+ PendingRequest.Return(pending);
+ }
+ }
+
+ // Mirrors ConsumerSpec.CreateTestHubs: a real MergeHub.Source (the shared client ingress)
+ // mapping each enriched request to a 200 response, fanned out via a BroadcastHub.
+ private (Sink, Source) CreateTestHubs()
+ {
+ var (sink, source) = MergeHub.Source(16)
+ .Via(Flow.Create().Select(req =>
+ new HttpResponseMessage(HttpStatusCode.OK) { RequestMessage = req }))
+ .ToMaterialized(BroadcastHub.Sink(256), Akka.Streams.Dsl.Keep.Both)
+ .Run(Materializer);
+ return (sink, source);
+ }
+}
diff --git a/src/TurboHTTP.Tests/Streams/Stages/Server/ApplicationBridgeStageSpec.cs b/src/TurboHTTP.Tests/Streams/Stages/Server/ApplicationBridgeStageSpec.cs
index d6af561de..c3a60425c 100644
--- a/src/TurboHTTP.Tests/Streams/Stages/Server/ApplicationBridgeStageSpec.cs
+++ b/src/TurboHTTP.Tests/Streams/Stages/Server/ApplicationBridgeStageSpec.cs
@@ -153,4 +153,77 @@ public void ApplicationBridgeStage_should_complete_upstream_finished_no_pending(
upstream.SendComplete(TestContext.Current.CancellationToken);
downstream.ExpectComplete(TestContext.Current.CancellationToken);
}
+
+ [Fact(Timeout = 5000)]
+ public async Task Buffered_async_handler_should_not_create_pipe()
+ {
+ var handlerEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var app = new FakeApplication(async features =>
+ {
+ handlerEntered.SetResult();
+ await release.Task;
+ var body = features.Get()!;
+ var writer = body.Writer;
+ var mem = writer.GetMemory(2 * 1024);
+ new byte[2 * 1024].CopyTo(mem);
+ writer.Advance(2 * 1024);
+ writer.Complete();
+ });
+
+ var stage = CreateStage(app);
+ var (upstream, downstream) = this.SourceProbe()
+ .Via(stage)
+ .ToMaterialized(this.SinkProbe(), Keep.Both)
+ .Run(Materializer);
+
+ downstream.Request(1);
+ upstream.SendNext(Request(), TestContext.Current.CancellationToken);
+
+ await handlerEntered.Task.WaitAsync(TimeSpan.FromSeconds(3),
+ TestContext.Current.CancellationToken);
+ release.SetResult();
+
+ var emitted = downstream.ExpectNext(TestContext.Current.CancellationToken);
+ var bodyFeature = emitted.Get() as TurboHttpResponseBodyFeature;
+ Assert.NotNull(bodyFeature);
+ Assert.False(bodyFeature!.HasPipe,
+ "Buffered async handler (no FlushAsync) should not create a Pipe");
+ }
+
+ [Fact(Timeout = 5000)]
+ public async Task Streaming_async_handler_should_create_pipe_on_flush()
+ {
+ var handlerEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var app = new FakeApplication(async features =>
+ {
+ handlerEntered.SetResult();
+ var body = features.Get()!;
+ await body.Writer.WriteAsync(new byte[512], TestContext.Current.CancellationToken);
+ await body.Writer.FlushAsync(TestContext.Current.CancellationToken);
+ await release.Task;
+ body.Writer.Complete();
+ });
+
+ var stage = CreateStage(app);
+ var (upstream, downstream) = this.SourceProbe()
+ .Via(stage)
+ .ToMaterialized(this.SinkProbe(), Keep.Both)
+ .Run(Materializer);
+
+ downstream.Request(1);
+ upstream.SendNext(Request(), TestContext.Current.CancellationToken);
+
+ await handlerEntered.Task.WaitAsync(TimeSpan.FromSeconds(3),
+ TestContext.Current.CancellationToken);
+
+ var emitted = downstream.ExpectNext(TestContext.Current.CancellationToken);
+ var bodyFeature = emitted.Get() as TurboHttpResponseBodyFeature;
+ Assert.NotNull(bodyFeature);
+ Assert.True(bodyFeature!.HasPipe,
+ "Streaming async handler (explicit FlushAsync) should create a Pipe");
+
+ release.SetResult();
+ }
}
diff --git a/src/TurboHTTP/Client/TurboClientServiceCollectionExtensions.cs b/src/TurboHTTP/Client/TurboClientServiceCollectionExtensions.cs
index 23c712840..2d55a06a4 100644
--- a/src/TurboHTTP/Client/TurboClientServiceCollectionExtensions.cs
+++ b/src/TurboHTTP/Client/TurboClientServiceCollectionExtensions.cs
@@ -41,14 +41,6 @@ public static ITurboHttpClientBuilder AddTurboHttpClient(this IServiceCollection
var system = provider.GetService();
if (system is null)
{
- // Derive dispatcher thread counts from the highest MaxEndpointSubstreams
- // across all registered clients.
- var optionsMonitor = provider.GetRequiredService>();
- var maxSubstreams = provider.GetServices()
- .Select(n => optionsMonitor.Get(n.Name).MaxConcurrentEndpoints)
- .DefaultIfEmpty(256u)
- .Max();
-
var loggerFactory = provider.GetService();
if (loggerFactory is not null)
{
@@ -64,8 +56,7 @@ public static ITurboHttpClientBuilder AddTurboHttpClient(this IServiceCollection
system = ActorSystem.Create("turbohttp", LoggingHocon);
}
- system.Log.Info("Created ActorSystem {0} — dispatchers sized from MaxEndpointSubstreams={1}",
- system.Name, maxSubstreams);
+ system.Log.Info("Created ActorSystem {0}", system.Name);
}
var options = provider.GetRequiredService>();
diff --git a/src/TurboHTTP/Internal/OptionsFactory.cs b/src/TurboHTTP/Internal/OptionsFactory.cs
index 70c999ec7..9d80dfcc1 100644
--- a/src/TurboHTTP/Internal/OptionsFactory.cs
+++ b/src/TurboHTTP/Internal/OptionsFactory.cs
@@ -51,7 +51,11 @@ internal static TransportOptions Build(RequestEndpoint endpoint, TurboClientOpti
MaxConnectionsPerHost = clientOptions.Http3.MaxConnectionsPerServer,
MaxBidirectionalStreams = clientOptions.Http3.MaxConcurrentStreams,
ApplicationProtocols = alpn,
- AutoReconnect = true,
+ // AutoReconnect stays at its default (false), matching TCP/TLS: reconnect is driven by
+ // Http3ClientStateMachine (StreamClosed(Error)/TransportDisconnected → OnConnectionLost →
+ // ConnectTransport + ReconnectionManager), exactly like H1.1/H2 over TCP. A transport-level
+ // auto-reconnect races the SM-driven one and aborts healthy concurrent streams on every
+ // transient — the H3-only instability seen in the 2026-06-19 benchmarks.
ConnectionLifetime = clientOptions.PooledConnectionLifetime
};
}
diff --git a/src/TurboHTTP/Internal/PendingRequest.cs b/src/TurboHTTP/Internal/PendingRequest.cs
index 8606e5a68..c0ebe553a 100644
--- a/src/TurboHTTP/Internal/PendingRequest.cs
+++ b/src/TurboHTTP/Internal/PendingRequest.cs
@@ -13,6 +13,11 @@ internal sealed class PendingRequest : IValueTaskSource
// Written only while the request is live (between Rent and Return); cleared before Return.
internal PendingRequest? Next;
+ // The channel-path default-timeout source (RequestEnricher rule 8). Disposed when the request
+ // completes so its TimerQueue entry is released immediately instead of lingering for the whole
+ // timeout window after every request (the channel path never returns the pending to the pool).
+ private CancellationTokenSource? _timeoutCts;
+
private PendingRequest()
{
}
@@ -25,15 +30,30 @@ public static PendingRequest Rent()
}
item._core.Reset();
+ item._timeoutCts = null;
return item;
}
public static void Return(PendingRequest item)
{
+ item.DisposeTimeoutCts();
item.Next = null;
Pool.Return(item);
}
+ ///
+ /// Attaches the channel-path default-timeout source so it is disposed when this request completes
+ /// (response delivered, faulted, or returned to the pool) rather than lingering until its timer fires.
+ ///
+ public void AttachTimeoutCts(CancellationTokenSource cts) => _timeoutCts = cts;
+
+ private void DisposeTimeoutCts()
+ {
+ var cts = _timeoutCts;
+ _timeoutCts = null;
+ cts?.Dispose();
+ }
+
public short Version => _core.Version;
public ValueTask GetValueTask() => new(this, _core.Version);
@@ -48,6 +68,7 @@ public bool TrySetResult(HttpResponseMessage response, short expectedVersion)
try
{
_core.SetResult(response);
+ DisposeTimeoutCts();
return true;
}
catch (InvalidOperationException)
@@ -66,6 +87,7 @@ public bool TrySetException(Exception exception, short expectedVersion)
try
{
_core.SetException(exception);
+ DisposeTimeoutCts();
return true;
}
catch (InvalidOperationException)
diff --git a/src/TurboHTTP/Pooling/CrossThreadBufferPool.cs b/src/TurboHTTP/Pooling/CrossThreadBufferPool.cs
new file mode 100644
index 000000000..6ec35d3ec
--- /dev/null
+++ b/src/TurboHTTP/Pooling/CrossThreadBufferPool.cs
@@ -0,0 +1,17 @@
+using System.Buffers;
+
+namespace TurboHTTP.Pooling;
+
+// One process-wide pool for body buffers that cross the connection-stage -> application thread
+// boundary. ArrayPool.Create uses global, locked per-bucket stacks (no per-core affinity),
+// so a buffer rented on the stage thread and returned on the app thread is reused instead of
+// missing the pool and forcing a fresh allocation (the failure mode of MemoryPool.Shared /
+// the per-core ArrayPool.Shared). Shared by QueuedBodyReader and BufferedBodyReader.
+internal static class CrossThreadBufferPool
+{
+ internal static readonly ArrayPool Shared =
+ ArrayPool.Create(maxArrayLength: 1024 * 1024, maxArraysPerBucket: 512);
+
+ public static IMemoryOwner Rent(int minimumLength)
+ => new PooledArrayMemoryOwner(Shared, minimumLength);
+}
diff --git a/src/TurboHTTP/Pooling/PooledArrayMemoryOwner.cs b/src/TurboHTTP/Pooling/PooledArrayMemoryOwner.cs
new file mode 100644
index 000000000..a8055593a
--- /dev/null
+++ b/src/TurboHTTP/Pooling/PooledArrayMemoryOwner.cs
@@ -0,0 +1,34 @@
+using System.Buffers;
+
+namespace TurboHTTP.Pooling;
+
+// An IMemoryOwner backed by a rented array from a caller-supplied ArrayPool. Returns the array
+// to that pool exactly once on Dispose. Used for body buffers that are rented on the connection-
+// stage thread and disposed on the application thread, where a process-wide pool with global,
+// locked per-bucket stacks keeps the rent/return hit rate intact across the thread hop.
+internal sealed class PooledArrayMemoryOwner : IMemoryOwner
+{
+ private readonly ArrayPool _pool;
+ private byte[]? _array;
+
+ public PooledArrayMemoryOwner(ArrayPool pool, int minimumLength)
+ {
+ _pool = pool;
+ _array = pool.Rent(minimumLength);
+ }
+
+ public Memory Memory
+ => _array ?? throw new ObjectDisposedException(nameof(PooledArrayMemoryOwner));
+
+ public void Dispose()
+ {
+ var array = _array;
+ if (array is null)
+ {
+ return;
+ }
+
+ _array = null;
+ _pool.Return(array);
+ }
+}
diff --git a/src/TurboHTTP/Protocol/Body/BufferedBodyReader.cs b/src/TurboHTTP/Protocol/Body/BufferedBodyReader.cs
index 3f7ba769e..1c209fa84 100644
--- a/src/TurboHTTP/Protocol/Body/BufferedBodyReader.cs
+++ b/src/TurboHTTP/Protocol/Body/BufferedBodyReader.cs
@@ -23,7 +23,7 @@ public void Reset(int contentLength)
_received = 0;
IsCompleted = contentLength == 0;
_owner = contentLength > 0
- ? MemoryPool.Shared.Rent(contentLength)
+ ? CrossThreadBufferPool.Rent(contentLength)
: null;
}
@@ -34,7 +34,7 @@ public void ResetOpenEnded()
_openEnded = true;
_received = 0;
IsCompleted = false;
- _owner = MemoryPool.Shared.Rent(4 * 1024);
+ _owner = CrossThreadBufferPool.Rent(4 * 1024);
}
void IResettable.Reset() => ResetOpenEnded();
@@ -78,7 +78,7 @@ private void EnsureCapacity(int needed)
}
var newSize = Math.Max(needed, (_owner?.Memory.Length ?? 4 * 1024) * 2);
- var next = MemoryPool.Shared.Rent(newSize);
+ var next = CrossThreadBufferPool.Rent(newSize);
if (_owner is not null && _received > 0)
{
_owner.Memory[.._received].CopyTo(next.Memory);
diff --git a/src/TurboHTTP/Protocol/Body/QueuedBodyReader.cs b/src/TurboHTTP/Protocol/Body/QueuedBodyReader.cs
index c433d5d88..89bd99230 100644
--- a/src/TurboHTTP/Protocol/Body/QueuedBodyReader.cs
+++ b/src/TurboHTTP/Protocol/Body/QueuedBodyReader.cs
@@ -13,15 +13,11 @@ internal sealed class QueuedBodyReader : IStreamingBodyReader, IValueTaskSource<
// never executes on the producing stage thread.
private readonly object _sync = new();
- // ArrayPool.Shared uses per-core thread-local stacks: this reader rents on the connection-
- // stage thread and returns on the application thread, so the returned buffer lands on a different
- // core's stack than the renting core inspects. Under concurrency that collapses the pool hit rate
- // and forces fresh allocations on the body path (measured ~2x on H1.1, ~12x on H2 at CL=32). A
- // single process-wide ConfigurableArrayPool uses global, locked per-bucket stacks with no core
- // affinity, so rent/return survive the thread hop. Rent/return semantics are identical, so the
- // reader's buffer-ownership logic is unaffected.
- private static readonly ArrayPool CrossThreadPool =
- ArrayPool.Create(maxArrayLength: 1024 * 1024, maxArraysPerBucket: 512);
+ // Body buffers are rented on the connection-stage thread and returned on the application thread.
+ // The shared process-wide pool uses global, locked per-bucket stacks (no core affinity), so the
+ // rent/return survives that hop where the per-core ArrayPool.Shared would miss and force a
+ // fresh allocation (measured ~2x on H1.1, ~12x on H2 at CL=32). See CrossThreadBufferPool.
+ private static readonly ArrayPool CrossThreadPool = CrossThreadBufferPool.Shared;
private readonly ArrayPool _pool;
private OwnedChunk[] _slots;
diff --git a/src/TurboHTTP/Protocol/Body/QueuedBodyStream.cs b/src/TurboHTTP/Protocol/Body/QueuedBodyStream.cs
index eddeeca75..e064c8455 100644
--- a/src/TurboHTTP/Protocol/Body/QueuedBodyStream.cs
+++ b/src/TurboHTTP/Protocol/Body/QueuedBodyStream.cs
@@ -69,6 +69,47 @@ public override async ValueTask ReadAsync(Memory buffer, Cancellation
return CopyFromCurrent(buffer.Span);
}
+ public override async Task CopyToAsync(Stream destination, int bufferSize, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(destination);
+
+ if (_done)
+ {
+ return;
+ }
+
+ // Finish any partially-read chunk from a prior Read/ReadAsync before draining the reader.
+ // The reader still owns this chunk's rental (AdvanceTo runs only once it is fully consumed),
+ // so release it after the write completes.
+ if (!_current.IsEmpty)
+ {
+ await destination.WriteAsync(_current[_offset..], cancellationToken).ConfigureAwait(false);
+ _current = default;
+ _offset = 0;
+ reader.AdvanceTo();
+ }
+
+ while (true)
+ {
+ var result = await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
+ if (result.IsCompleted)
+ {
+ _done = true;
+ return;
+ }
+
+ // Write the pooled chunk straight to the destination — no per-read copy into an
+ // 81920-byte framework rental. AdvanceTo (which returns the rental to the pool) runs
+ // only AFTER the write await completes, so the buffer is never recycled while in use.
+ if (!result.Memory.IsEmpty)
+ {
+ await destination.WriteAsync(result.Memory, cancellationToken).ConfigureAwait(false);
+ }
+
+ reader.AdvanceTo();
+ }
+ }
+
private int CopyFromCurrent(Span destination)
{
var available = _current.Length - _offset;
diff --git a/src/TurboHTTP/Protocol/HuffmanCodec.cs b/src/TurboHTTP/Protocol/HuffmanCodec.cs
index 92d6f921a..139263e7b 100644
--- a/src/TurboHTTP/Protocol/HuffmanCodec.cs
+++ b/src/TurboHTTP/Protocol/HuffmanCodec.cs
@@ -140,23 +140,66 @@ public static int Encode(ReadOnlySpan input, Span output)
return pos;
}
- private static readonly HuffmanNode Root = BuildTree();
+ // Flat, contiguous decode tree built once from HpackHuffmanTable. Node 0 is the root; child
+ // links are array indices (NoChild = no edge), so decoding walks a cache-resident struct[] by
+ // index instead of chasing scattered heap HuffmanNode pointers (the original cache bottleneck).
+ private const int NoChild = -1;
+ private const int NoSymbol = -1;
- private static HuffmanNode BuildTree()
+ private readonly record struct HuffmanTreeNode(int Zero, int One, int Symbol);
+
+ private static readonly HuffmanTreeNode[] DecodeTree = BuildDecodeTree();
+
+ private static HuffmanTreeNode[] BuildDecodeTree()
{
- var root = new HuffmanNode();
+ var zero = new List { NoChild };
+ var one = new List { NoChild };
+ var symbol = new List { NoSymbol };
+
for (var sym = 0; sym < HpackHuffmanTable.Length; sym++)
{
var (code, bits) = HpackHuffmanTable[sym];
- root.Insert((int)code, bits, sym);
+ var node = 0;
+ for (var i = bits - 1; i >= 0; i--)
+ {
+ var bit = (int)((code >> i) & 1);
+ var child = bit == 0 ? zero[node] : one[node];
+ if (child == NoChild)
+ {
+ child = zero.Count;
+ zero.Add(NoChild);
+ one.Add(NoChild);
+ symbol.Add(NoSymbol);
+
+ if (bit == 0)
+ {
+ zero[node] = child;
+ }
+ else
+ {
+ one[node] = child;
+ }
+ }
+
+ node = child;
+ }
+
+ symbol[node] = sym;
}
- return root;
+ var tree = new HuffmanTreeNode[zero.Count];
+ for (var i = 0; i < tree.Length; i++)
+ {
+ tree[i] = new HuffmanTreeNode(zero[i], one[i], symbol[i]);
+ }
+
+ return tree;
}
public static int Decode(ReadOnlySpan input, Span output)
{
- var node = Root;
+ var tree = DecodeTree;
+ var node = 0;
var pos = 0;
var remainingBits = 0;
@@ -168,9 +211,9 @@ public static int Decode(ReadOnlySpan input, Span output)
{
var isOne = ((b >> bit) & 1) == 1;
- node = isOne ? node.One : node.Zero;
+ node = isOne ? tree[node].One : tree[node].Zero;
- if (node is null)
+ if (node == NoChild)
{
throw new HuffmanException(
$"Invalid Huffman-encoded data: no valid symbol at bit offset {remainingBits} (input byte 0x{b:X2}).");
@@ -179,7 +222,8 @@ public static int Decode(ReadOnlySpan input, Span output)
remainingBits++;
remainingValue = (remainingValue << 1) | (isOne ? 1 : 0);
- if (node.Symbol is not { } sym)
+ var sym = tree[node].Symbol;
+ if (sym == NoSymbol)
{
continue;
}
@@ -191,7 +235,7 @@ public static int Decode(ReadOnlySpan input, Span output)
}
output[pos++] = (byte)sym;
- node = Root;
+ node = 0;
remainingBits = 0;
remainingValue = 0;
}
@@ -215,36 +259,4 @@ public static int Decode(ReadOnlySpan input, Span output)
return pos;
}
-
- private sealed class HuffmanNode
- {
- public HuffmanNode? Zero { get; private set; }
- public HuffmanNode? One { get; private set; }
-
- public int? Symbol { get; private set; }
-
- public bool IsEosPadding { get; private set; }
-
- public void Insert(int code, int bits, int symbol)
- {
- var node = this;
- for (var i = bits - 1; i >= 0; i--)
- {
- var bit = (code >> i) & 1;
- if (bit == 0)
- {
- node.Zero ??= new HuffmanNode();
- node = node.Zero;
- }
- else
- {
- node.One ??= new HuffmanNode();
- node = node.One;
- }
- }
-
- node.Symbol = symbol;
- node.IsEosPadding = symbol == 256;
- }
- }
}
\ No newline at end of file
diff --git a/src/TurboHTTP/Protocol/LineBased/BufferSearch.cs b/src/TurboHTTP/Protocol/LineBased/BufferSearch.cs
index 1bf2a0e50..1391d4797 100644
--- a/src/TurboHTTP/Protocol/LineBased/BufferSearch.cs
+++ b/src/TurboHTTP/Protocol/LineBased/BufferSearch.cs
@@ -9,26 +9,11 @@ public static int FindCrlf(ReadOnlySpan data, int start)
return -1;
}
- var slice = data[start..];
- var offset = 0;
- while (offset < slice.Length)
- {
- var cr = slice[offset..].IndexOf((byte)'\r');
- if (cr < 0)
- {
- return -1;
- }
-
- var idx = offset + cr;
- if (idx + 1 < slice.Length && slice[idx + 1] == (byte)'\n')
- {
- return start + idx;
- }
-
- offset = idx + 1;
- }
-
- return -1;
+ // Single vectorized two-byte search instead of "find CR, then check the next byte and
+ // restart on a lone CR". Same semantics: the first CRLF pair at or after start (a lone CR
+ // is never matched; CR-CR-LF matches the second CR), with no scan restarts.
+ var idx = data[start..].IndexOf("\r\n"u8);
+ return idx < 0 ? -1 : start + idx;
}
public static int FindCrlfCrlf(ReadOnlySpan data, int start)
diff --git a/src/TurboHTTP/Protocol/LineBased/HeaderFieldParser.cs b/src/TurboHTTP/Protocol/LineBased/HeaderFieldParser.cs
index dcf6765ff..52dfb1350 100644
--- a/src/TurboHTTP/Protocol/LineBased/HeaderFieldParser.cs
+++ b/src/TurboHTTP/Protocol/LineBased/HeaderFieldParser.cs
@@ -45,7 +45,7 @@ public static bool TryParse(ReadOnlySpan line, out string name, out string
return false;
}
- name = WellKnownHeaders.GetOrCreateHeaderNameIgnoreCase(nameSpan).Name;
+ name = WellKnownHeaders.GetOrCreateHeaderNameStringIgnoreCase(nameSpan);
value = valueSpan.IsEmpty ? string.Empty : WellKnownHeaders.GetOrCreateHeaderValueString(valueSpan);
return true;
}
diff --git a/src/TurboHTTP/Protocol/LineBased/RequestLineWriter.cs b/src/TurboHTTP/Protocol/LineBased/RequestLineWriter.cs
index 34a0276d8..fb5f37bb1 100644
--- a/src/TurboHTTP/Protocol/LineBased/RequestLineWriter.cs
+++ b/src/TurboHTTP/Protocol/LineBased/RequestLineWriter.cs
@@ -6,13 +6,13 @@ internal static class RequestLineWriter
{
public static void Write(ref SpanWriter writer, string methodName, string target, Version version)
{
- var versionStr = MessageVersionCodec.ToWireFormat(version);
-
writer.WriteAscii(methodName);
writer.WriteSpace();
writer.WriteAscii(target);
writer.WriteSpace();
- writer.WriteAscii(versionStr);
+ // Write the pre-encoded u8 version bytes directly (matching StatusLineWriter) instead of
+ // re-running Encoding.ASCII.GetBytes over the version string on every request.
+ writer.WriteBytes(MessageVersionCodec.ToWireBytes(version));
writer.WriteCrlf();
}
}
\ No newline at end of file
diff --git a/src/TurboHTTP/Protocol/Semantics/ConnectionSemantics.cs b/src/TurboHTTP/Protocol/Semantics/ConnectionSemantics.cs
index b23dc89e7..817e3921a 100644
--- a/src/TurboHTTP/Protocol/Semantics/ConnectionSemantics.cs
+++ b/src/TurboHTTP/Protocol/Semantics/ConnectionSemantics.cs
@@ -23,11 +23,18 @@ public static bool IsPersistent(HeaderCollection headers, Version version)
var hasKeepAlive = false;
var hasClose = false;
- foreach (var v in headers.GetValues(WellKnownHeaders.Connection))
+ for (var i = 0; i < headers.Count; i++)
{
- foreach (var part in v.AsSpan().Split(','))
+ var entry = headers[i];
+ if (!string.Equals(entry.Name, WellKnownHeaders.Connection, StringComparison.OrdinalIgnoreCase))
{
- var t = HeaderValidation.TrimOws(v[part.Start..part.End]);
+ continue;
+ }
+
+ var value = entry.Value;
+ foreach (var part in value.AsSpan().Split(','))
+ {
+ var t = HeaderValidation.TrimOws(value[part.Start..part.End]);
if (string.IsNullOrEmpty(t))
{
continue;
diff --git a/src/TurboHTTP/Protocol/Semantics/HeaderCollection.cs b/src/TurboHTTP/Protocol/Semantics/HeaderCollection.cs
index fddfcd54b..fe3cf46f3 100644
--- a/src/TurboHTTP/Protocol/Semantics/HeaderCollection.cs
+++ b/src/TurboHTTP/Protocol/Semantics/HeaderCollection.cs
@@ -15,6 +15,8 @@ internal sealed class HeaderCollection : IEnumerable
public int Count => _entries.Count;
+ public HeaderEntry this[int index] => _entries[index];
+
public void Add(string name, string value)
{
_entries.Add(new HeaderEntry(name, value));
@@ -33,6 +35,10 @@ public IEnumerable GetValues(string name)
public string? GetCombined(string name)
{
+ // Single-value fast path (the common Content-Length / Transfer-Encoding case): return the
+ // stored value string directly, allocating neither a StringBuilder nor a copied string.
+ // The builder is created lazily only once a second matching value is seen.
+ var firstIndex = -1;
StringBuilder? sb = null;
for (var i = 0; i < _entries.Count; i++)
{
@@ -41,17 +47,23 @@ public IEnumerable GetValues(string name)
continue;
}
- if (sb is null)
+ if (firstIndex < 0)
{
- sb = new StringBuilder(_entries[i].Value);
+ firstIndex = i;
}
else
{
+ sb ??= new StringBuilder(_entries[firstIndex].Value);
sb.Append(WellKnownHeaders.CommaSpace).Append(_entries[i].Value);
}
}
- return sb?.ToString();
+ if (sb is not null)
+ {
+ return sb.ToString();
+ }
+
+ return firstIndex >= 0 ? _entries[firstIndex].Value : null;
}
public bool Contains(string name)
@@ -84,7 +96,12 @@ public int WireSize()
return size;
}
- public IEnumerator GetEnumerator() => _entries.GetEnumerator();
+ // Return the List's struct enumerator so `foreach` over a HeaderCollection (e.g. HeaderBlockWriter)
+ // does not box an IEnumerator on every message. The interface implementations stay for IEnumerable
+ // consumers (LINQ), which box regardless.
+ public List.Enumerator GetEnumerator() => _entries.GetEnumerator();
+
+ IEnumerator IEnumerable.GetEnumerator() => _entries.GetEnumerator();
- IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+ IEnumerator IEnumerable.GetEnumerator() => _entries.GetEnumerator();
}
\ No newline at end of file
diff --git a/src/TurboHTTP/Protocol/Syntax/Http11/Client/Http11ClientStateMachine.cs b/src/TurboHTTP/Protocol/Syntax/Http11/Client/Http11ClientStateMachine.cs
index 33046388f..2af83f7ce 100644
--- a/src/TurboHTTP/Protocol/Syntax/Http11/Client/Http11ClientStateMachine.cs
+++ b/src/TurboHTTP/Protocol/Syntax/Http11/Client/Http11ClientStateMachine.cs
@@ -30,6 +30,7 @@ internal sealed class Http11ClientStateMachine : IClientStateMachine, IBodyDrain
private IStreamingBodyReader? _activeStreamingReader;
private TransportBuffer? _heldBuffer;
private int _heldBufferOffset;
+ private TransportBuffer? _partialResponse;
private bool _draining;
private SerialBodyPump? _serialPump;
private CancellationTokenSource? _connectionCts;
@@ -231,6 +232,14 @@ public void DecodeServerData(ITransportInbound data)
return;
}
+ // Prepend any unconsumed prefix retained from the previous read — an incomplete status line
+ // or header line split across the read boundary — so the decoder resumes from it instead of
+ // losing it (which would desync the connection and fault subsequent pipelined responses).
+ if (_partialResponse is not null)
+ {
+ buffer = CombineWithPartial(buffer);
+ }
+
DecodeResponse(buffer);
}
@@ -321,6 +330,7 @@ public void Cleanup()
_heldBuffer?.Dispose();
_heldBuffer = null;
_heldBufferOffset = 0;
+ ClearPartial();
_connectionCloseReceived = false;
_draining = false;
_serialPump?.Cleanup();
@@ -370,6 +380,13 @@ private void DecodeResponse(TransportBuffer buffer, int startOffset = 0)
_heldBufferOffset = offset;
bufferHeld = true;
}
+ else if (offset < memory.Length)
+ {
+ // Incomplete status line / header (or a split frame header) with no streaming
+ // back-pressure: retain the unconsumed prefix so it survives this buffer's
+ // disposal and is re-presented ahead of the next read.
+ RetainPartial(memory.Span[offset..]);
+ }
return;
}
@@ -419,6 +436,8 @@ private void DecodeResponse(TransportBuffer buffer, int startOffset = 0)
_pendingBodyResponse = null;
_activeStreamingReader = null;
_decoder.Reset();
+ // The byte stream is desynced after a decode failure; any retained prefix is now garbage.
+ ClearPartial();
}
finally
{
@@ -429,6 +448,39 @@ private void DecodeResponse(TransportBuffer buffer, int startOffset = 0)
}
}
+ // Merges the retained partial prefix with the next inbound buffer into a single contiguous
+ // buffer, disposing both inputs. The caller takes ownership of (and disposes) the result.
+ private TransportBuffer CombineWithPartial(TransportBuffer incoming)
+ {
+ var partial = _partialResponse!;
+ _partialResponse = null;
+
+ var combined = TransportBuffer.Rent(partial.Length + incoming.Length);
+ partial.Span.CopyTo(combined.FullMemory.Span);
+ incoming.Span.CopyTo(combined.FullMemory.Span[partial.Length..]);
+ combined.Length = partial.Length + incoming.Length;
+
+ partial.Dispose();
+ incoming.Dispose();
+ return combined;
+ }
+
+ // Copies the unconsumed prefix into a freshly rented buffer so it outlives the current
+ // (about-to-be-disposed) inbound buffer. Bounded by the decoder's max header size.
+ private void RetainPartial(ReadOnlySpan remainder)
+ {
+ var buf = TransportBuffer.Rent(remainder.Length);
+ remainder.CopyTo(buf.FullMemory.Span);
+ buf.Length = remainder.Length;
+ _partialResponse = buf;
+ }
+
+ private void ClearPartial()
+ {
+ _partialResponse?.Dispose();
+ _partialResponse = null;
+ }
+
private void StartBodyDrain(Stream bodyStream, long? contentLength, Version httpVersion)
{
_isChunked = contentLength is null && !httpVersion.Equals(HttpVersion.Version10);
@@ -440,6 +492,9 @@ private void StartBodyDrain(Stream bodyStream, long? contentLength, Version http
private void HandleDisconnect(TransportDisconnected disconnect)
{
+ // The connection's byte stream is gone; a retained partial prefix from it is now stale.
+ ClearPartial();
+
var isGraceful = disconnect.Reason == DisconnectReason.Graceful;
if (isGraceful)
diff --git a/src/TurboHTTP/Protocol/Syntax/Http11/Server/Http11ServerDecoder.cs b/src/TurboHTTP/Protocol/Syntax/Http11/Server/Http11ServerDecoder.cs
index dbc79a7a8..1089d6e2b 100644
--- a/src/TurboHTTP/Protocol/Syntax/Http11/Server/Http11ServerDecoder.cs
+++ b/src/TurboHTTP/Protocol/Syntax/Http11/Server/Http11ServerDecoder.cs
@@ -177,13 +177,17 @@ public bool HasConnectionClose
{
get
{
- foreach (var v in _headerReader.GetHeaders().GetValues(WellKnownHeaders.Connection))
+ var headers = _headerReader.GetHeaders();
+ for (var i = 0; i < headers.Count; i++)
{
- if (ConnectionHeaderSemantics.HasCloseOption(v))
+ var entry = headers[i];
+ if (string.Equals(entry.Name, WellKnownHeaders.Connection, StringComparison.OrdinalIgnoreCase)
+ && ConnectionHeaderSemantics.HasCloseOption(entry.Value))
{
return true;
}
}
+
return false;
}
}
diff --git a/src/TurboHTTP/Protocol/Syntax/Http11/Server/Http11ServerStateMachine.cs b/src/TurboHTTP/Protocol/Syntax/Http11/Server/Http11ServerStateMachine.cs
index a31310f50..3d17d00df 100644
--- a/src/TurboHTTP/Protocol/Syntax/Http11/Server/Http11ServerStateMachine.cs
+++ b/src/TurboHTTP/Protocol/Syntax/Http11/Server/Http11ServerStateMachine.cs
@@ -376,39 +376,41 @@ public void OnResponse(IFeatureCollection features)
features.Get()?.Method, "HEAD", StringComparison.OrdinalIgnoreCase);
var suppressBody = isHeadRequest || statusCode is >= 100 and < 200 or 204 or 304;
- var contentLength = ExtractContentLength(responseFeature);
- var hasExplicitChunked = false;
- if (responseFeature?.Headers is { } responseHeaders)
- {
- foreach (var h in responseHeaders)
- {
- if (!h.Key.Equals(WellKnownHeaders.TransferEncoding, StringComparison.OrdinalIgnoreCase))
- {
- continue;
- }
-
- foreach (var v in h.Value)
- {
- if (v != null && v.Equals(WellKnownHeaders.ChunkedValue, StringComparison.OrdinalIgnoreCase))
- {
- hasExplicitChunked = true;
- break;
- }
- }
-
- if (hasExplicitChunked)
- {
- break;
- }
- }
- }
+ // Single pass over the response headers computes Content-Length, explicit chunked framing, and
+ // the header-buffer size estimate together, instead of three separate iterations (each a boxed
+ // IHeaderDictionary enumerator) over the same dictionary.
+ var headerScan = ScanResponseHeaders(responseFeature);
+ var contentLength = headerScan.ContentLength;
+ var hasExplicitChunked = headerScan.HasExplicitChunked;
var isChunked = !suppressBody && (contentLength is null || hasExplicitChunked);
- var estimatedSize = EstimateResponseHeaderSize(responseFeature);
- var responseBuffer = TransportBuffer.Rent(estimatedSize);
+ // Resolve a fully-buffered response body once (the dominant Content-Length case). A non-
+ // chunked buffered body is coalesced into the SAME buffer as the status line + headers,
+ // emitting one outbound item instead of two: it removes a TransportBuffer/TransportData
+ // rent, a GraphInterpreter push, and (transport permitting) a socket write per response.
+ // The body is already materialized and copied synchronously on the existing path too, so
+ // buffer ownership is unchanged. Streamed bodies report false here; chunked bodies keep the
+ // framed EmitBufferedBody path below.
+ var turboBody = responseBody as TurboHttpResponseBodyFeature;
+ ReadOnlyMemory bufferedBody = default;
+ var hasBufferedBody = !suppressBody
+ && turboBody is not null
+ && turboBody.TryGetBufferedBody(out bufferedBody);
+ var coalesceBody = hasBufferedBody && !isChunked;
+
+ var estimatedSize = headerScan.EstimatedSize;
+ var responseBuffer = TransportBuffer.Rent(
+ coalesceBody ? estimatedSize + bufferedBody.Length : estimatedSize);
var span = responseBuffer.FullMemory.Span;
var written = _encoder.Encode(span, features, isChunked, connectionClose: ShouldComplete);
+
+ if (coalesceBody && !bufferedBody.IsEmpty)
+ {
+ bufferedBody.Span.CopyTo(span[written..]);
+ written += bufferedBody.Length;
+ }
+
responseBuffer.Length = written;
_ops.OnOutbound(TransportData.Rent(responseBuffer));
@@ -448,9 +450,23 @@ public void OnResponse(IFeatureCollection features)
}
}
- if (responseBody is TurboHttpResponseBodyFeature turboBody)
+ if (turboBody is not null)
{
- if (turboBody.TryGetBufferedBody(out var bufferedBody))
+ if (coalesceBody)
+ {
+ // Body bytes were folded into the header buffer above: nothing more to emit.
+ _ops.OnResponseBodyComplete(features);
+ Tracing.For("Protocol").Debug(this,
+ "response body complete (buffered, coalesced, bytes={0})", bufferedBody.Length);
+ if (!ShouldComplete && _keepAliveTimeout > TimeSpan.Zero && _pendingResponseCount == 0)
+ {
+ _ops.OnScheduleTimer(KeepAliveTimer, _keepAliveTimeout);
+ }
+
+ return;
+ }
+
+ if (hasBufferedBody)
{
EmitBufferedBody(features, bufferedBody, isChunked);
return;
@@ -614,51 +630,67 @@ public void OnOutboundFlushed()
}
}
- private static int EstimateResponseHeaderSize(IHttpResponseFeature? responseFeature)
+ internal readonly struct ResponseHeaderScan(long? contentLength, bool hasExplicitChunked, int estimatedSize)
+ {
+ public long? ContentLength { get; } = contentLength;
+ public bool HasExplicitChunked { get; } = hasExplicitChunked;
+ public int EstimatedSize { get; } = estimatedSize;
+ }
+
+ ///
+ /// Single pass over the response headers computing the Content-Length, whether Transfer-Encoding
+ /// declares chunked, and the header-buffer size estimate together — replacing three separate
+ /// iterations (each a boxed IHeaderDictionary enumerator) over the same dictionary per response.
+ ///
+ internal static ResponseHeaderScan ScanResponseHeaders(IHttpResponseFeature? responseFeature)
{
const int statusLineOverhead = 32;
const int perHeaderOverhead = 4;
const int trailingCrlf = 2;
+ const int slack = 128;
const int minimumSize = 256;
- if (responseFeature?.Headers is null)
+ if (responseFeature?.Headers is not { } headers)
{
- return minimumSize;
+ return new ResponseHeaderScan(null, false, minimumSize);
}
+ long? contentLength = null;
+ var hasExplicitChunked = false;
var estimate = statusLineOverhead + trailingCrlf;
- foreach (var header in responseFeature.Headers)
+
+ foreach (var header in headers)
{
estimate += header.Key.Length + perHeaderOverhead;
foreach (var v in header.Value)
{
estimate += v?.Length ?? 0;
}
- }
-
- estimate += 128;
- return Math.Max(minimumSize, estimate);
- }
-
- private static long? ExtractContentLength(IHttpResponseFeature? responseFeature)
- {
- if (responseFeature?.Headers is null)
- {
- return null;
- }
- foreach (var header in responseFeature.Headers)
- {
- if (header.Key.Equals(WellKnownHeaders.ContentLength, StringComparison.OrdinalIgnoreCase)
+ if (contentLength is null
+ && header.Key.Equals(WellKnownHeaders.ContentLength, StringComparison.OrdinalIgnoreCase)
&& header.Value.Count > 0
- && header.Value[0] is { } value
- && ContentLengthSemantics.TryParse(value, out var length))
+ && header.Value[0] is { } clValue
+ && ContentLengthSemantics.TryParse(clValue, out var parsed))
{
- return length;
+ contentLength = parsed;
+ }
+ else if (!hasExplicitChunked
+ && header.Key.Equals(WellKnownHeaders.TransferEncoding, StringComparison.OrdinalIgnoreCase))
+ {
+ foreach (var v in header.Value)
+ {
+ if (v != null && v.Equals(WellKnownHeaders.ChunkedValue, StringComparison.OrdinalIgnoreCase))
+ {
+ hasExplicitChunked = true;
+ break;
+ }
+ }
}
}
- return null;
+ estimate += slack;
+ return new ResponseHeaderScan(contentLength, hasExplicitChunked, Math.Max(minimumSize, estimate));
}
private bool TryHandleH2cUpgrade(IFeatureCollection features)
diff --git a/src/TurboHTTP/Protocol/Syntax/Http2/Client/Http2ClientSessionManager.cs b/src/TurboHTTP/Protocol/Syntax/Http2/Client/Http2ClientSessionManager.cs
index 822f11238..27d70d851 100644
--- a/src/TurboHTTP/Protocol/Syntax/Http2/Client/Http2ClientSessionManager.cs
+++ b/src/TurboHTTP/Protocol/Syntax/Http2/Client/Http2ClientSessionManager.cs
@@ -44,6 +44,7 @@ internal sealed class Http2ClientSessionManager : IBodyDrainTarget
public bool CanOpenStream => _tracker.CanOpenStream();
public bool GoAwayReceived => _flow.GoAwayReceived;
public int GoAwayLastStreamId { get; private set; }
+ public bool GoAwayWasGraceful { get; private set; }
public bool HasInFlightRequests => _correlationMap.Count > 0 || _streams.Count > 0;
public bool HasActiveStreams => _streams.Count > 0;
public RequestEndpoint Endpoint { get; private set; }
@@ -306,6 +307,9 @@ private bool TrySerializeBodyDirect(HttpContent content, int streamId, StreamSta
public IReadOnlyList DecodeFrames(TransportBuffer buffer)
{
+ // Decode returns the decoder's reused frame list; the only caller
+ // (Http2ClientStateMachine.OnInbound) iterates it synchronously within the same actor
+ // message and never retains it across Decode calls.
return _frameDecoder.Decode(buffer);
}
@@ -410,6 +414,33 @@ public IReadOnlyDictionary GetCorrelationMap()
return _correlationMap;
}
+ ///
+ /// True if any in-flight request occupies a stream id at or below —
+ /// i.e. a stream the GOAWAY sender committed to finish (RFC 9113 §6.8). When present, a graceful
+ /// GOAWAY is drained on the open connection; when absent there is nothing to wait for, so the
+ /// connection is reconnected immediately to replay the discarded streams.
+ ///
+ public bool HasInFlightStreamsAtOrBelow(int lastStreamId)
+ {
+ foreach (var streamId in _correlationMap.Keys)
+ {
+ if (streamId <= lastStreamId)
+ {
+ return true;
+ }
+ }
+
+ foreach (var streamId in _streams.Keys)
+ {
+ if (streamId <= lastStreamId)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
public bool HasReceivedHeaders(int streamId)
{
return _streams.GetValueOrDefault(streamId)?.HasResponse ?? false;
@@ -435,6 +466,7 @@ public void ResetConnectionState()
_requestEncoder.ResetHpack();
_responseDecoder.ResetHpack();
_prefaceSent = false;
+ GoAwayWasGraceful = false;
}
public void Cleanup()
@@ -604,8 +636,9 @@ private void HandleGoAway(GoAwayFrame goAway)
{
_flow.OnGoAway();
GoAwayLastStreamId = goAway.LastStreamId;
+ GoAwayWasGraceful = goAway.ErrorCode == Http2ErrorCode.NoError;
Tracing.For("Protocol").Info(this,
- "HTTP/2: GOAWAY received from {0} - LastStreamId={1}, ErrorCode={2}. Reconnecting", Endpoint.Host,
+ "HTTP/2: GOAWAY received from {0} - LastStreamId={1}, ErrorCode={2}", Endpoint.Host,
goAway.LastStreamId, goAway.ErrorCode);
}
diff --git a/src/TurboHTTP/Protocol/Syntax/Http2/Client/Http2ClientStateMachine.cs b/src/TurboHTTP/Protocol/Syntax/Http2/Client/Http2ClientStateMachine.cs
index 243e20a3f..fd81715e5 100644
--- a/src/TurboHTTP/Protocol/Syntax/Http2/Client/Http2ClientStateMachine.cs
+++ b/src/TurboHTTP/Protocol/Syntax/Http2/Client/Http2ClientStateMachine.cs
@@ -58,7 +58,10 @@ public void DecodeServerData(ITransportInbound data)
return;
case TransportDisconnected when _clientSession.HasInFlightRequests:
- OnConnectionLost(lastStreamId: 0);
+ // If we were draining a graceful GOAWAY, classify the still-open streams against that
+ // GOAWAY's last-stream-id: streams above it were provably not processed and can be
+ // replayed regardless of method, while streams at/below it follow the idempotent rule.
+ OnConnectionLost(_clientSession.GoAwayReceived ? _clientSession.GoAwayLastStreamId : 0);
return;
case TransportDisconnected:
@@ -93,7 +96,23 @@ public void DecodeServerData(ITransportInbound data)
if (_clientSession is { GoAwayReceived: true, HasInFlightRequests: true })
{
- OnConnectionLost(_clientSession.GoAwayLastStreamId);
+ // RFC 9113 §6.8: a graceful (NO_ERROR) GOAWAY keeps the connection open until in-progress
+ // streams complete. Don't tear it down — let ALL in-flight streams keep draining here
+ // (dropping an in-flight non-idempotent POST is exactly the failure seen under load when a
+ // server graceful-closes after a batch). New requests already route elsewhere because
+ // CanAcceptRequest is now false. Streams the server discarded (above LastStreamId) never get
+ // a response and stay in flight until the server closes the connection, at which point the
+ // TransportDisconnected path above replays them using the remembered LastStreamId. We only
+ // tear the connection down immediately when there is nothing to wait for: a non-graceful
+ // (error) GOAWAY, or a graceful GOAWAY whose LastStreamId is below every in-flight stream
+ // (the server committed to finish none of them — e.g. LastStreamId=0), in which case
+ // draining would just stall until the server closes.
+ if (!_clientSession.GoAwayWasGraceful
+ || !_clientSession.HasInFlightStreamsAtOrBelow(_clientSession.GoAwayLastStreamId))
+ {
+ OnConnectionLost(_clientSession.GoAwayLastStreamId);
+ }
+
return;
}
diff --git a/src/TurboHTTP/Protocol/Syntax/Http2/FlowController.cs b/src/TurboHTTP/Protocol/Syntax/Http2/FlowController.cs
index f1dd4a685..128da2977 100644
--- a/src/TurboHTTP/Protocol/Syntax/Http2/FlowController.cs
+++ b/src/TurboHTTP/Protocol/Syntax/Http2/FlowController.cs
@@ -172,7 +172,16 @@ public FlowControlResult OnInboundData(int streamId, int dataLength)
{
increment += newWindow - _initialRecvStreamWindow;
_initialRecvStreamWindow = newWindow;
- _windowUpdateThreshold = Math.Max(8 * 1024, newWindow / 4);
+
+ // Do NOT grow _windowUpdateThreshold with the scaled receive window. The threshold
+ // gates when a stream WINDOW_UPDATE is emitted, and it must stay below the window the
+ // SERVER enforces for a freshly opened stream — which is the advertised
+ // SETTINGS_INITIAL_WINDOW_SIZE (we never re-advertise a larger one), not our scaled
+ // receive window. If the threshold grew past the advertised initial, a new stream's
+ // server send window would be exhausted before the client ever accumulated enough to
+ // emit a WINDOW_UPDATE, deadlocking the stream after the first ~1 MB. Keeping it at
+ // advertised/4 keeps replenishment ahead of the server on every stream.
+ // Repro: TurboHTTP.IntegrationTests.Client/H2/LargeDownloadRegressionSpec.
}
}
@@ -207,7 +216,10 @@ public void RemoveStreamSendWindow(int streamId)
public void ApplyInitialWindowSizeDelta(long delta)
{
_initialSendStreamWindow += delta;
- foreach (var streamId in _streamSendWindows.Keys.ToList())
+
+ // Updating an existing key's value does not bump the dictionary version, so the keys can be
+ // iterated in place without the per-SETTINGS Keys.ToList() snapshot allocation.
+ foreach (var streamId in _streamSendWindows.Keys)
{
_streamSendWindows[streamId] += delta;
}
diff --git a/src/TurboHTTP/Protocol/Syntax/Http2/FrameDecoder.cs b/src/TurboHTTP/Protocol/Syntax/Http2/FrameDecoder.cs
index dd14aea92..02dcc1dae 100644
--- a/src/TurboHTTP/Protocol/Syntax/Http2/FrameDecoder.cs
+++ b/src/TurboHTTP/Protocol/Syntax/Http2/FrameDecoder.cs
@@ -69,17 +69,24 @@ public FrameDecoder(int maxFrameSize = (int)MaxMaxFrameSize)
private int _awaitingContinuationStreamId;
///
- /// Feeds bytes and returns all complete frames decoded so far.
+ /// Feeds bytes and returns the decoder's reused list of all complete frames decoded so far.
/// Transfers ownership of : the caller must not use it after this call.
/// Incomplete trailing bytes are retained inside the decoder for the next call.
+ /// The returned list is reused and repopulated on every call, so callers MUST fully consume it
+ /// before the next Decode and MUST NOT retain it. The client/server state machines iterate it
+ /// synchronously within the same actor message under Akka back-pressure; a caller that needs to
+ /// hold a result across calls must snapshot it (e.g. ToArray()).
///
public IReadOnlyList Decode(TransportBuffer buffer)
{
+ // Cleared first so the early-return (nothing-new) path cannot surface a prior call's frames.
+ _frames.Clear();
+
// Fast path: nothing new and nothing buffered.
if (buffer.Length == 0 && _remainderLength == 0)
{
buffer.Dispose();
- return [];
+ return _frames;
}
int workingLength;
@@ -123,7 +130,6 @@ public IReadOnlyList Decode(TransportBuffer buffer)
var offset = startOffset;
var working = _workingBuffer.FullMemory;
- _frames.Clear();
while (workingLength - offset >= FrameHeaderSize)
{
@@ -162,12 +168,8 @@ public IReadOnlyList Decode(TransportBuffer buffer)
_remainderOffset = offset;
_remainderLength = workingLength - offset;
- if (_frames.Count == 0)
- {
- return [];
- }
- return _frames.ToArray();
+ return _frames;
}
///
diff --git a/src/TurboHTTP/Protocol/Syntax/Http2/Hpack/HpackDecoder.cs b/src/TurboHTTP/Protocol/Syntax/Http2/Hpack/HpackDecoder.cs
index 818236cde..bdd9a3f99 100644
--- a/src/TurboHTTP/Protocol/Syntax/Http2/Hpack/HpackDecoder.cs
+++ b/src/TurboHTTP/Protocol/Syntax/Http2/Hpack/HpackDecoder.cs
@@ -125,7 +125,8 @@ public List Decode(ReadOnlySpan data)
tableSizeUpdateAllowed = false;
var (header, nbl, vbl) = ReadLiteralHeaderWithLengths(data, ref pos, prefixBits: 6, neverIndex: false);
CheckHeaderListSize(ref cumulativeHeaderListSize, nbl, vbl);
- _table.Add(header.Name, header.Value);
+ // Reuse the byte lengths just read instead of recomputing GetByteCount inside Add.
+ _table.Add(header.Name, header.Value, nbl, vbl);
_headers.Add(header);
}
// RFC 7541 §6.3: Dynamic Table Size Update - bit pattern: 001xxxxx
diff --git a/src/TurboHTTP/Protocol/Syntax/Http2/Hpack/HpackDynamicTable.cs b/src/TurboHTTP/Protocol/Syntax/Http2/Hpack/HpackDynamicTable.cs
index 2b35e6e86..3930ca1f9 100644
--- a/src/TurboHTTP/Protocol/Syntax/Http2/Hpack/HpackDynamicTable.cs
+++ b/src/TurboHTTP/Protocol/Syntax/Http2/Hpack/HpackDynamicTable.cs
@@ -1,5 +1,3 @@
-using System.Text;
-
namespace TurboHTTP.Protocol.Syntax.Http2.Hpack;
///
@@ -50,11 +48,13 @@ public void SetMaxSize(int newMax)
///
/// RFC 7541 §4.4 - Adds a new entry to the front of the table.
/// If the entry alone exceeds MaxSize, the entire table is cleared.
+ /// Callers pass the raw UTF-8 byte lengths they already computed (the HPACK encoder's
+ /// WriteString pass / the decoder's literal read), avoiding a redundant
+ /// GetByteCount on the hot path. The provided lengths
+ /// MUST equal Encoding.UTF8.GetByteCount of the respective string.
///
- public void Add(string name, string value)
+ public void Add(string name, string value, int nameByteLength, int valueByteLength)
{
- var nameByteLength = Encoding.UTF8.GetByteCount(name);
- var valueByteLength = Encoding.UTF8.GetByteCount(value);
var entrySize = nameByteLength + valueByteLength + 32;
if (entrySize > MaxSize)
diff --git a/src/TurboHTTP/Protocol/Syntax/Http2/Hpack/HpackEncoder.cs b/src/TurboHTTP/Protocol/Syntax/Http2/Hpack/HpackEncoder.cs
index d9e68f6f5..e8b8235db 100644
--- a/src/TurboHTTP/Protocol/Syntax/Http2/Hpack/HpackEncoder.cs
+++ b/src/TurboHTTP/Protocol/Syntax/Http2/Hpack/HpackEncoder.cs
@@ -227,19 +227,29 @@ private int WriteLiteral(HpackHeader header, int nameIndex, HpackEncoding encodi
_ => throw new HpackException($"Unknown HpackEncoding value: {encoding}")
};
+ var nameByteLength = -1;
+
// When nameIndex == 0, emit the name as a string literal
if (nameIndex == 0)
{
- written += WriteString(header.Name, ref output, useHuffman);
+ written += WriteString(header.Name, ref output, useHuffman, out nameByteLength);
}
// Always emit value as a string literal
- written += WriteString(header.Value, ref output, useHuffman);
+ written += WriteString(header.Value, ref output, useHuffman, out var valueByteLength);
- // Update dynamic table for IncrementalIndexing only (RFC 7541 §6.2.1)
+ // Update dynamic table for IncrementalIndexing only (RFC 7541 §6.2.1). Reuse the raw UTF-8
+ // byte lengths WriteString already computed instead of recomputing GetByteCount inside Add;
+ // when the name came from a table reference (nameIndex != 0) WriteString never ran for it,
+ // so fall back to computing it once (matching the previous behaviour).
if (encoding == HpackEncoding.IncrementalIndexing)
{
- _table.Add(header.Name, header.Value);
+ if (nameByteLength < 0)
+ {
+ nameByteLength = Encoding.UTF8.GetByteCount(header.Name);
+ }
+
+ _table.Add(header.Name, header.Value, nameByteLength, valueByteLength);
}
return written;
@@ -314,9 +324,10 @@ internal static int WriteInteger(int value, int prefixBits, byte prefixFlags, re
/// length against the raw length and picks whichever is shorter (RFC 7541 §5.2).
/// Writes directly into the caller-provided span.
///
- private static int WriteString(string value, ref Span output, bool useHuffman)
+ private static int WriteString(string value, ref Span output, bool useHuffman, out int rawByteLength)
{
var rawLength = Encoding.UTF8.GetByteCount(value);
+ rawByteLength = rawLength;
if (useHuffman && rawLength > 0)
{
diff --git a/src/TurboHTTP/Protocol/Syntax/Http2/Server/Http2ServerSessionManager.cs b/src/TurboHTTP/Protocol/Syntax/Http2/Server/Http2ServerSessionManager.cs
index a4e7f61f5..ea0601664 100644
--- a/src/TurboHTTP/Protocol/Syntax/Http2/Server/Http2ServerSessionManager.cs
+++ b/src/TurboHTTP/Protocol/Syntax/Http2/Server/Http2ServerSessionManager.cs
@@ -156,6 +156,8 @@ public void DecodeClientData(TransportBuffer buffer)
SkipConnectionPreface(buffer);
}
+ // Decode returns the decoder's reused frame list; iterate it synchronously here within
+ // the same actor message and never retain it (Akka back-pressure guarantees consumption).
var frames = _frameDecoder.Decode(buffer);
for (var i = 0; i < frames.Count; i++)
{
@@ -300,15 +302,7 @@ public void OnResponse(IFeatureCollection features)
var window = _flow.GetSendWindow(streamId);
if (window >= bufferedBody.Length)
{
- var maxFrame = _responseEncoder.MaxFrameSize;
- var remaining = bufferedBody;
- while (remaining.Length > maxFrame)
- {
- EmitFrame(new DataFrame(streamId, remaining[..maxFrame], endStream: false));
- remaining = remaining[maxFrame..];
- }
-
- EmitFrame(new DataFrame(streamId, remaining, endStream: true));
+ EmitBufferedDataFrames(streamId, bufferedBody, endStream: true);
_flow.OnDataSent(streamId, bufferedBody.Length);
CloseStream(streamId);
return;
@@ -854,20 +848,13 @@ private void CloseStream(int streamId)
private void SendBufferedBodyWithFlowControl(int streamId, StreamState state, ReadOnlyMemory body,
long window)
{
- var maxFrame = _responseEncoder.MaxFrameSize;
var sent = 0;
if (window > 0)
{
- var sendable = body[..(int)Math.Min(window, body.Length)];
- while (sendable.Length > maxFrame)
- {
- EmitFrame(new DataFrame(streamId, sendable[..maxFrame], endStream: false));
- sendable = sendable[maxFrame..];
- }
-
- EmitFrame(new DataFrame(streamId, sendable, endStream: false));
sent = (int)Math.Min(window, body.Length);
+ var sendable = body[..sent];
+ EmitBufferedDataFrames(streamId, sendable, endStream: false);
_flow.OnDataSent(streamId, sent);
}
@@ -892,21 +879,12 @@ private void SendBufferedBodyWithFlowControl(int streamId, StreamState state, Re
void IBodyDrainTarget.EmitDataFrames(int streamId, ReadOnlyMemory data, bool endStream)
{
- // The server ignores the endStream flag on EmitDataFrames because the end-of-body
- // may require trailers (HEADERS frame) rather than a simple END_STREAM DATA frame.
- // OnDrainComplete handles the trailer-aware end-of-body signaling.
- var maxFrame = _responseEncoder.MaxFrameSize;
- var remaining = data;
- while (remaining.Length > maxFrame)
+ if (data.IsEmpty)
{
- EmitFrame(new DataFrame(streamId, remaining[..maxFrame], endStream: false));
- remaining = remaining[maxFrame..];
+ return;
}
- if (!remaining.IsEmpty)
- {
- EmitFrame(new DataFrame(streamId, remaining, endStream: false));
- }
+ EmitBufferedDataFrames(streamId, data, endStream: false);
}
void IBodyDrainTarget.OnDrainComplete(int streamId)
@@ -929,6 +907,54 @@ void IBodyDrainTarget.OnDrainFailed(int streamId, Exception reason)
EmitRstStream(streamId, Http2ErrorCode.InternalError);
}
+ private void EmitBufferedDataFrames(int streamId, ReadOnlyMemory body, bool endStream)
+ {
+ const int headerSize = 9;
+ var maxFrame = _responseEncoder.MaxFrameSize;
+ var frameCount = (body.Length + maxFrame - 1) / maxFrame;
+ var totalWireSize = body.Length + frameCount * headerSize;
+
+ var buf = TransportBuffer.Rent(totalWireSize);
+ var dest = buf.FullMemory.Span;
+ var offset = 0;
+ var remaining = body;
+ var rateActive = false;
+
+ while (remaining.Length > maxFrame)
+ {
+ var chunk = remaining[..maxFrame];
+ DataFrame.WriteHeaderInPlace(dest, offset, streamId, maxFrame, endStream: false);
+ chunk.Span.CopyTo(dest[(offset + headerSize)..]);
+ offset += headerSize + maxFrame;
+ remaining = remaining[maxFrame..];
+
+ Tracing.For("Protocol").Trace(this, "HTTP/2: DATA out (stream={0}, len={1}, endStream={2})",
+ streamId, maxFrame, false);
+ rateActive = true;
+ }
+
+ var lastLen = remaining.Length;
+ DataFrame.WriteHeaderInPlace(dest, offset, streamId, lastLen, endStream);
+ remaining.Span.CopyTo(dest[(offset + headerSize)..]);
+ offset += headerSize + lastLen;
+
+ Tracing.For("Protocol").Trace(this, "HTTP/2: DATA out (stream={0}, len={1}, endStream={2})",
+ streamId, lastLen, endStream);
+ if (lastLen > 0)
+ {
+ rateActive = true;
+ }
+
+ if (rateActive)
+ {
+ _responseRate.Observe(streamId, body.Length, Now());
+ EnsureRateTimer();
+ }
+
+ buf.Length = offset;
+ _ops.OnOutbound(TransportData.Rent(buf));
+ }
+
private void EmitFrame(Http2Frame frame)
{
if (frame is DataFrame d)
diff --git a/src/TurboHTTP/Protocol/Syntax/Http3/Client/Http3ClientStateMachine.cs b/src/TurboHTTP/Protocol/Syntax/Http3/Client/Http3ClientStateMachine.cs
index c7b3e8472..58fb39032 100644
--- a/src/TurboHTTP/Protocol/Syntax/Http3/Client/Http3ClientStateMachine.cs
+++ b/src/TurboHTTP/Protocol/Syntax/Http3/Client/Http3ClientStateMachine.cs
@@ -22,6 +22,13 @@ internal sealed class Http3ClientStateMachine : IClientStateMachine
private readonly Server.ServerStreamResolver _serverStreamResolver;
+ // QUIC reports a connection failure as StreamClosed(Error) per stream FOLLOWED by a
+ // TransportDisconnected. When the per-stream errors already drove the reconnect, the trailing
+ // TransportDisconnected belongs to the SAME failure and must be swallowed once — not counted as a
+ // failed reconnect attempt. Set when a stream-error starts the reconnect; consumed by the next
+ // TransportDisconnected and cleared on a successful reconnect.
+ private bool _expectTrailingDisconnect;
+
public bool CanAcceptRequest => !Connection.GoAwayReceived && !IsReconnecting && _clientSession.CanOpenStream;
public bool IsReconnecting => _reconnect.IsReconnecting;
@@ -92,13 +99,22 @@ public void DecodeServerData(ITransportInbound data)
case TransportDisconnected when IsReconnecting:
{
+ // A trailing disconnect from a stream-error-driven failure (StreamClosed(Error) per
+ // stream + a final TransportDisconnected) is the same failure, not a failed reconnect
+ // attempt — swallow it once. A later disconnect IS the new connect attempt failing.
+ if (_expectTrailingDisconnect)
+ {
+ _expectTrailingDisconnect = false;
+ return;
+ }
+
OnReconnectAttemptFailed();
return;
}
case TransportDisconnected when HasInFlightRequests:
{
- OnConnectionLost();
+ OnConnectionLost(expectTrailingDisconnect: false);
return;
}
@@ -135,7 +151,7 @@ public void DecodeServerData(ITransportInbound data)
Connection.OnStreamClosed();
if (streamClosed.Reason == DisconnectReason.Error)
{
- OnConnectionLost();
+ OnConnectionLost(expectTrailingDisconnect: true);
}
else
{
@@ -268,8 +284,21 @@ public void Cleanup()
return new GoAwayFrame(0);
}
- private void OnConnectionLost()
+ private void OnConnectionLost(bool expectTrailingDisconnect)
{
+ // Idempotent: QUIC surfaces one connection failure as a StreamClosed(Error) PER stream (plus a
+ // trailing TransportDisconnected), so this can fire several times for a single failure. Only the
+ // first call may capture the in-flight requests and start the reconnect. A second call would
+ // re-buffer an ALREADY-DRAINED (empty) correlation map via ReconnectionManager.OnConnectionLost,
+ // wiping the replay set (losing those requests) and emitting a duplicate ConnectTransport. This
+ // mirrors TCP, where the transport reports a single disconnect and the state machine owns reconnect.
+ if (IsReconnecting)
+ {
+ return;
+ }
+
+ _expectTrailingDisconnect = expectTrailingDisconnect;
+
Tracing.For("Protocol").Info(this, "HTTP/3: connection lost (inFlight={0})", HasInFlightRequests);
var correlations = _clientSession.GetCorrelationMap().Values.ToList();
_reconnect.OnConnectionLost(correlations);
@@ -286,6 +315,7 @@ private void OnConnectionLost()
private void OnConnectionRestored()
{
+ _expectTrailingDisconnect = false;
Tracing.For("Protocol").Info(this, "HTTP/3: connection restored");
var preface = _clientSession.TryBuildControlPreface();
if (preface is not null)
diff --git a/src/TurboHTTP/Protocol/WellKnownHeaders.cs b/src/TurboHTTP/Protocol/WellKnownHeaders.cs
index 429bc129f..70ece8d71 100644
--- a/src/TurboHTTP/Protocol/WellKnownHeaders.cs
+++ b/src/TurboHTTP/Protocol/WellKnownHeaders.cs
@@ -743,86 +743,101 @@ public static string GetOrCreateHeaderValueString(ReadOnlySpan value)
=> TryResolve(value, out var cached) ? cached : Encoding.ASCII.GetString(value);
public static WellKnownHeader GetOrCreateHeaderNameIgnoreCase(ReadOnlySpan name)
- => name.Length switch
+ => TryResolveCachedNameIgnoreCase(name, out var cached) ? cached : new WellKnownHeader(name);
+
+ ///
+ /// Resolves a header NAME to its string form without constructing a
+ /// for unknown names (whose ctor allocates an ASCII byte copy via
+ /// that the decode path never reads — only is used). Returns the
+ /// interned cached name when well-known, otherwise the freshly-decoded string.
+ ///
+ public static string GetOrCreateHeaderNameStringIgnoreCase(ReadOnlySpan name)
+ => TryResolveCachedNameIgnoreCase(name, out var cached) ? cached.Name : Encoding.ASCII.GetString(name);
+
+ private static bool TryResolveCachedNameIgnoreCase(ReadOnlySpan name, out WellKnownHeader cached)
+ {
+ cached = name.Length switch
{
- 0 => new WellKnownHeader(string.Empty),
- 2 => EqualsIgnoreCase(name, Te) ? Te : new WellKnownHeader(name),
+ 2 => EqualsIgnoreCase(name, Te) ? Te : default,
3 => EqualsIgnoreCase(name, Age) ? Age :
- EqualsIgnoreCase(name, Via) ? Via : new WellKnownHeader(name),
+ EqualsIgnoreCase(name, Via) ? Via : default,
4 => EqualsIgnoreCase(name, Date) ? Date :
EqualsIgnoreCase(name, ETag) ? ETag :
EqualsIgnoreCase(name, Vary) ? Vary :
EqualsIgnoreCase(name, From) ? From :
EqualsIgnoreCase(name, Host) ? Host :
- EqualsIgnoreCase(name, Link) ? Link : new WellKnownHeader(name),
- 5 => EqualsIgnoreCase(name, Allow) ? Allow : new WellKnownHeader(name),
+ EqualsIgnoreCase(name, Link) ? Link : default,
+ 5 => EqualsIgnoreCase(name, Allow) ? Allow : default,
6 => EqualsIgnoreCase(name, Accept) ? Accept :
EqualsIgnoreCase(name, Cookie) ? Cookie :
EqualsIgnoreCase(name, Expect) ? Expect :
EqualsIgnoreCase(name, Pragma) ? Pragma :
EqualsIgnoreCase(name, Server) ? Server :
- new WellKnownHeader(name),
+ default,
7 => EqualsIgnoreCase(name, AltSvc) ? AltSvc :
EqualsIgnoreCase(name, Expires) ? Expires :
EqualsIgnoreCase(name, Referer) ? Referer :
EqualsIgnoreCase(name, Trailer) ? Trailer :
EqualsIgnoreCase(name, Upgrade) ? Upgrade :
EqualsIgnoreCase(name, Warning) ? Warning :
- new WellKnownHeader(name),
+ default,
8 => EqualsIgnoreCase(name, IfMatch) ? IfMatch :
EqualsIgnoreCase(name, IfRange) ? IfRange :
EqualsIgnoreCase(name, Location) ? Location :
- new WellKnownHeader(name),
+ default,
9 => EqualsIgnoreCase(name, Forwarded)
? Forwarded
- : new WellKnownHeader(name),
+ : default,
10 => EqualsIgnoreCase(name, Connection) ? Connection :
EqualsIgnoreCase(name, KeepAliveHeader) ? KeepAliveHeader :
EqualsIgnoreCase(name, SetCookie) ? SetCookie :
EqualsIgnoreCase(name, UserAgent) ? UserAgent :
- new WellKnownHeader(name),
+ default,
11 => EqualsIgnoreCase(name, RetryAfter) ? RetryAfter :
EqualsIgnoreCase(name, SetCookie2) ? SetCookie2 :
- new WellKnownHeader(name),
+ default,
12 => EqualsIgnoreCase(name, ContentType) ? ContentType :
EqualsIgnoreCase(name, MaxForwards) ? MaxForwards :
EqualsIgnoreCase(name, XRequestId) ? XRequestId :
- new WellKnownHeader(name),
+ default,
13 => EqualsIgnoreCase(name, Authorization) ? Authorization :
EqualsIgnoreCase(name, CacheControl) ? CacheControl :
EqualsIgnoreCase(name, ContentRange) ? ContentRange :
EqualsIgnoreCase(name, LastModified) ? LastModified :
EqualsIgnoreCase(name, IfNoneMatch) ? IfNoneMatch :
- new WellKnownHeader(name),
+ default,
14 => EqualsIgnoreCase(name, AcceptCharset) ? AcceptCharset :
EqualsIgnoreCase(name, AcceptRanges) ? AcceptRanges :
EqualsIgnoreCase(name, ContentLength) ? ContentLength :
- new WellKnownHeader(name),
+ default,
15 => EqualsIgnoreCase(name, AcceptEncoding) ? AcceptEncoding :
EqualsIgnoreCase(name, AcceptLanguage) ? AcceptLanguage :
EqualsIgnoreCase(name, XForwardedFor) ? XForwardedFor :
- new WellKnownHeader(name),
+ default,
16 => EqualsIgnoreCase(name, ContentEncoding) ? ContentEncoding :
EqualsIgnoreCase(name, ContentLanguage) ? ContentLanguage :
EqualsIgnoreCase(name, ContentLocation) ? ContentLocation :
EqualsIgnoreCase(name, WwwAuthenticate) ? WwwAuthenticate :
- new WellKnownHeader(name),
+ default,
17 => EqualsIgnoreCase(name, IfModifiedSince) ? IfModifiedSince :
EqualsIgnoreCase(name, TransferEncoding) ? TransferEncoding :
EqualsIgnoreCase(name, XForwardedProto) ? XForwardedProto :
- new WellKnownHeader(name),
+ default,
18 => EqualsIgnoreCase(name, ProxyAuthenticate)
? ProxyAuthenticate
- : new WellKnownHeader(name),
+ : default,
19 => EqualsIgnoreCase(name, IfUnmodifiedSince) ? IfUnmodifiedSince :
EqualsIgnoreCase(name, ProxyAuthorization) ? ProxyAuthorization :
- new WellKnownHeader(name),
+ default,
25 => EqualsIgnoreCase(name, StrictTransportSecurity)
? StrictTransportSecurity
- : new WellKnownHeader(name),
- _ => new WellKnownHeader(name)
+ : default,
+ _ => default
};
+ return cached.Name is not null;
+ }
+
internal static bool EqualsIgnoreCase(ReadOnlySpan a, ReadOnlySpan b)
{
if (a.Length != b.Length)
diff --git a/src/TurboHTTP/Server/Context/Features/TurboHttpResponseBodyFeature.cs b/src/TurboHTTP/Server/Context/Features/TurboHttpResponseBodyFeature.cs
index eba150569..2c3958898 100644
--- a/src/TurboHTTP/Server/Context/Features/TurboHttpResponseBodyFeature.cs
+++ b/src/TurboHTTP/Server/Context/Features/TurboHttpResponseBodyFeature.cs
@@ -381,12 +381,12 @@ public override ValueTask FlushAsync(CancellationToken cancellation
return CommitAndFlushAsync(cancellationToken);
}
- if (_owner._pipe is not null)
+ if (_owner._pipe is null)
{
- return _owner._pipe.Writer.FlushAsync(cancellationToken);
+ _owner.UpgradeToPipe();
}
- return new ValueTask(new FlushResult(false, false));
+ return _owner._pipe!.Writer.FlushAsync(cancellationToken);
}
public override ValueTask WriteAsync(ReadOnlyMemory source,
@@ -397,16 +397,12 @@ public override ValueTask WriteAsync(ReadOnlyMemory source,
return CommitAndWriteAsync(source, cancellationToken);
}
- if (_owner._pipe is not null)
+ if (_owner._pipe is null)
{
- return _owner._pipe.Writer.WriteAsync(source, cancellationToken);
+ _owner.UpgradeToPipe();
}
- var dest = _owner._bufferWriter.GetSpan(source.Length);
- source.Span.CopyTo(dest);
- _owner._bufferWriter.Advance(source.Length);
- BytesWritten += source.Length;
- return new ValueTask(new FlushResult(false, false));
+ return _owner._pipe!.Writer.WriteAsync(source, cancellationToken);
}
private async ValueTask CommitAndFlushAsync(CancellationToken cancellationToken)
@@ -424,15 +420,12 @@ private async ValueTask CommitAndFlushAsync(CancellationToken cance
SignalHeadersReady();
}
- // Stay buffered unless a streaming consumer already upgraded us to a pipe. A flush on a
- // buffered response is a no-op (the body is emitted on completion), matching the
- // post-HasStarted buffered FlushAsync path.
- if (_owner._pipe is not null)
+ if (_owner._pipe is null)
{
- return await _owner._pipe.Writer.FlushAsync(cancellationToken);
+ _owner.UpgradeToPipe();
}
- return new FlushResult(false, false);
+ return await _owner._pipe!.Writer.FlushAsync(cancellationToken);
}
private async ValueTask CommitAndWriteAsync(ReadOnlyMemory source,
@@ -453,19 +446,12 @@ private async ValueTask CommitAndWriteAsync(ReadOnlyMemory so
BytesWritten += source.Length;
- // A response that commits and completes without a streaming consumer never needs a Pipe
- // (the dominant Plaintext/Json case) — keep it buffered, mirroring the GetSpan/Advance
- // path. Genuine streaming handlers are upgraded to a pipe by the bridge before they
- // write; UpgradeToPipe migrates any already-buffered content.
- if (_owner._pipe is not null)
+ if (_owner._pipe is null)
{
- return await _owner._pipe.Writer.WriteAsync(source, cancellationToken);
+ _owner.UpgradeToPipe();
}
- var dest = _owner._bufferWriter.GetSpan(source.Length);
- source.Span.CopyTo(dest);
- _owner._bufferWriter.Advance(source.Length);
- return new FlushResult(false, false);
+ return await _owner._pipe!.Writer.WriteAsync(source, cancellationToken);
}
public override void Complete(Exception? exception = null)
diff --git a/src/TurboHTTP/Streams/Lifecycle/ConnectionActor.cs b/src/TurboHTTP/Streams/Lifecycle/ConnectionActor.cs
index 837a021f6..fba681e2f 100644
--- a/src/TurboHTTP/Streams/Lifecycle/ConnectionActor.cs
+++ b/src/TurboHTTP/Streams/Lifecycle/ConnectionActor.cs
@@ -36,7 +36,11 @@ public ConnectionActor(
TurboServerOptions options,
IServiceProvider? services = null)
{
- var materializer = Context.Materializer();
+ // Mirror the client's StreamOwner tuning: the default 16/16 input buffer throttles H2
+ // multiplexing (more in-flight elements per materialized stream); H1.1 rarely fills it.
+ var materializerSettings = ActorMaterializerSettings.Create(Context.System)
+ .WithInputBuffer(initialSize: 32, maxSize: 128);
+ var materializer = Context.Materializer(materializerSettings);
_drainSwitch = KillSwitches.Shared(string.Concat("conn-", connectionId));
var protocolBidi = engine.CreateFlow(services);
diff --git a/src/TurboHTTP/Streams/Lifecycle/Consumer.cs b/src/TurboHTTP/Streams/Lifecycle/Consumer.cs
index 48ad348d3..149165e4b 100644
--- a/src/TurboHTTP/Streams/Lifecycle/Consumer.cs
+++ b/src/TurboHTTP/Streams/Lifecycle/Consumer.cs
@@ -25,6 +25,11 @@ internal sealed record ConsumerSinkCompleted(Exception? Error);
private UniqueKillSwitch? _sinkKillSwitch;
+ // Non-null sentinel for requests dropped by failure isolation in the ingress. Akka.Streams
+ // forbids null elements (Reactive Streams rule 2.13), so a failed enrichment returns this
+ // marker and is filtered out before the shared MergeHub. Never sent or mutated.
+ private static readonly HttpRequestMessage DroppedRequest = new();
+
public static Props Props(
Guid consumerId,
ChannelReader requestReader,
@@ -87,16 +92,43 @@ private void MaterializeIngress()
var cid = _consumerId;
ChannelSource.FromReader(_requestReader)
- .Select(request =>
+ .Select(request => TryEnrich(request, enricher, cid))
+ .Where(static request => !ReferenceEquals(request, DroppedRequest))
+ .RunWith(_requestIngress, _materializer);
+ }
+
+ ///
+ /// Stamps the consumer id and enriches a request, isolating any per-request failure so it can
+ /// never fail the SHARED ingress. If enrichment throws —
+ /// e.g. the caller disposed the after cancelling and the pipeline
+ /// then dereferenced it ( set throws ObjectDisposedException) —
+ /// a bare Select would propagate the failure into the MergeHub, tear this consumer's producer off
+ /// the hub, and strand every other in-flight request on the client. Instead we complete the
+ /// offending request's pending with the error (version-guarded, so a pooled/reused pending is never
+ /// corrupted) and drop the element from the stream.
+ ///
+ private HttpRequestMessage TryEnrich(HttpRequestMessage request, RequestEnricher enricher, Guid cid)
+ {
+ try
+ {
+ if (!request.Options.TryGetValue(OptionsKey.ConsumerIdKey, out _))
{
- if (!request.Options.TryGetValue(OptionsKey.ConsumerIdKey, out _))
- {
- request.Options.Set(OptionsKey.ConsumerIdKey, cid);
- }
+ request.Options.Set(OptionsKey.ConsumerIdKey, cid);
+ }
- return enricher.Enrich(request);
- })
- .RunWith(_requestIngress, _materializer);
+ return enricher.Enrich(request);
+ }
+ catch (Exception ex)
+ {
+ if (request.Options.TryGetValue(OptionsKey.Key, out var pending)
+ && request.Options.TryGetValue(OptionsKey.VersionKey, out var version))
+ {
+ pending.TrySetException(ex, version);
+ }
+
+ _log.Debug("Consumer {0} dropped a request whose enrichment failed: {1}", _consumerId, ex.Message);
+ return DroppedRequest;
+ }
}
private void MaterializeResponseSink()
diff --git a/src/TurboHTTP/Streams/Stages/Client/RequestEnricher.cs b/src/TurboHTTP/Streams/Stages/Client/RequestEnricher.cs
index bd10b0f5d..74c767bc1 100644
--- a/src/TurboHTTP/Streams/Stages/Client/RequestEnricher.cs
+++ b/src/TurboHTTP/Streams/Stages/Client/RequestEnricher.cs
@@ -103,6 +103,11 @@ public HttpRequestMessage Enrich(HttpRequestMessage request)
cts.Token.UnsafeRegister(
static (state, ct) => ((PendingRequest)state!).TrySetCanceled(ct),
pending);
+
+ // Hand ownership of the timer-backed source to the pending request so it is
+ // disposed the moment the response is delivered, instead of lingering (and holding
+ // a TimerQueue slot) for the whole timeout window after every channel-path request.
+ pending.AttachTimeoutCts(cts);
}
}
}
diff --git a/src/TurboHTTP/Streams/Stages/Server/ApplicationBridgeStage.cs b/src/TurboHTTP/Streams/Stages/Server/ApplicationBridgeStage.cs
index b82e5dae8..fefce5781 100644
--- a/src/TurboHTTP/Streams/Stages/Server/ApplicationBridgeStage.cs
+++ b/src/TurboHTTP/Streams/Stages/Server/ApplicationBridgeStage.cs
@@ -291,7 +291,6 @@ private void DispatchAsync(IFeatureCollection features, int seq)
ScheduleOnce(softKey, _stage._handlerTimeout);
var bodyFeature = features.Get() as TurboHttpResponseBodyFeature;
- bodyFeature?.UpgradeToPipe();
var headersReady = bodyFeature?.WhenHeadersReady;
if (headersReady is not null)