From 96a62fde7656297e4a5f3ee6d10d86fb6a3e297d Mon Sep 17 00:00:00 2001 From: Haga Rak Date: Thu, 9 Apr 2026 21:40:49 +0200 Subject: [PATCH 01/26] Update test script --- benchmark-throughput.cmd | 23 ++++++++++++++++++++++- benchmark-throughput.sh | 27 +++++++++++++++++++++++++-- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/benchmark-throughput.cmd b/benchmark-throughput.cmd index 671b0917..9d1ce715 100644 --- a/benchmark-throughput.cmd +++ b/benchmark-throughput.cmd @@ -1,8 +1,29 @@ @echo off setlocal +set FILTER= +set SHORT_ARGS= + +:parse_args +if "%~1"=="" goto done_args +if /i "%~1"=="--short" ( + set SHORT_ARGS=--warmupCount 1 --iterationCount 3 --launchCount 1 + shift + goto parse_args +) +if /i "%~1"=="--h2-8k" ( + rem H2 + 8192 body only, ~30%% of default duration + set SHORT_ARGS=--warmupCount 2 --iterationCount 5 --launchCount 1 + set FILTER=*ProxyThroughputBenchmark*True*8192* + shift + goto parse_args +) set FILTER=%~1 +shift +goto parse_args +:done_args + if "%FILTER%"=="" set FILTER=*ProxyThroughputBenchmark* dotnet build fluxzy.core.slnx -c Release -v q --nologo -dotnet run --project test/Fluxzy.Benchmarks -c Release --no-build -- --filter "%FILTER%" +dotnet run --project test/Fluxzy.Benchmarks -c Release --no-build -- --filter "%FILTER%" %SHORT_ARGS% diff --git a/benchmark-throughput.sh b/benchmark-throughput.sh index e2675ffa..0165c606 100755 --- a/benchmark-throughput.sh +++ b/benchmark-throughput.sh @@ -1,7 +1,30 @@ #!/usr/bin/env bash set -euo pipefail -FILTER="${1:-*ProxyThroughputBenchmark*}" +SHORT_ARGS="" +FILTER="" + +# Parse options +while [[ $# -gt 0 ]]; do + case "$1" in + --short) + SHORT_ARGS="--warmupCount 1 --iterationCount 3 --launchCount 1" + shift + ;; + --h2-8k) + # H2 + 8192 body only, ~30% of default duration + SHORT_ARGS="--warmupCount 2 --iterationCount 5 --launchCount 1" + FILTER="*ProxyThroughputBenchmark*True*8192*" + shift + ;; + *) + FILTER="$1" + shift + ;; + esac +done + +FILTER="${FILTER:-*ProxyThroughputBenchmark*}" dotnet build fluxzy.core.slnx -c Release -v q --nologo -dotnet run --project test/Fluxzy.Benchmarks -c Release --no-build -- --filter "$FILTER" +dotnet run --project test/Fluxzy.Benchmarks -c Release --no-build -- --filter "$FILTER" $SHORT_ARGS From 8b2ed9d1d37bf962f7d773c2c8e0ef295c6a3d0d Mon Sep 17 00:00:00 2001 From: haga-rak Date: Fri, 10 Apr 2026 00:57:43 +0200 Subject: [PATCH 02/26] Improve benchmark condition --- src/Fluxzy.Core/Core/CircularWriteBuffer.cs | 2 +- src/Fluxzy.Core/Core/H2DownStreamPipe.cs | 6 +++--- test/Fluxzy.Benchmarks/ProxyThroughputBenchmark.cs | 4 +++- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/Fluxzy.Core/Core/CircularWriteBuffer.cs b/src/Fluxzy.Core/Core/CircularWriteBuffer.cs index 9d75f3b9..85e03297 100644 --- a/src/Fluxzy.Core/Core/CircularWriteBuffer.cs +++ b/src/Fluxzy.Core/Core/CircularWriteBuffer.cs @@ -77,7 +77,7 @@ public void Write(ReadOnlySpan data) if (data.Length > _capacity) throw new InvalidOperationException( $"Frame size {data.Length} exceeds ring buffer capacity {_capacity}"); - + lock (_lock) { // Wait for sufficient free space while (_capacity - _count < data.Length) { diff --git a/src/Fluxzy.Core/Core/H2DownStreamPipe.cs b/src/Fluxzy.Core/Core/H2DownStreamPipe.cs index 4337957c..dcf9d23b 100644 --- a/src/Fluxzy.Core/Core/H2DownStreamPipe.cs +++ b/src/Fluxzy.Core/Core/H2DownStreamPipe.cs @@ -96,8 +96,8 @@ public H2DownStreamPipe( _headerEncoder = new HeaderEncoder(hPackEncoder, hPackDecoder, _h2StreamSetting); _logger = new H2Logger(requestedAuthority, -1); _ringBuffer = new CircularWriteBuffer(RingBufferCapacity, SignalWriteLoop); - _dataChannel = Channel.CreateBounded( - new BoundedChannelOptions(256) { SingleReader = true }); + _dataChannel = Channel.CreateUnbounded( + new UnboundedChannelOptions() { SingleReader = true }); _mainLoopTokenSource = new CancellationTokenSource(); _mainLoopToken = _mainLoopTokenSource.Token; } @@ -519,7 +519,7 @@ await _writeStream.WriteAsync( break; } - + await _writeSignal.WaitAsync(token).ConfigureAwait(false); } } diff --git a/test/Fluxzy.Benchmarks/ProxyThroughputBenchmark.cs b/test/Fluxzy.Benchmarks/ProxyThroughputBenchmark.cs index 496157f1..6efc35c1 100644 --- a/test/Fluxzy.Benchmarks/ProxyThroughputBenchmark.cs +++ b/test/Fluxzy.Benchmarks/ProxyThroughputBenchmark.cs @@ -26,7 +26,7 @@ namespace Fluxzy.Benchmarks; public class ProxyThroughputBenchmark { private const int RequestsPerIteration = 500; - private const int Concurrency = 16; + private const int Concurrency = 56; private BenchmarkServerProcess _server = null!; private Proxy _proxy = null!; @@ -54,6 +54,8 @@ public async Task Setup() .WhenAny() .Do(new SkipRemoteCertificateValidationAction()); // -k flag + setting.SetConnectionPerHost(63); + _proxy = new Proxy(setting); var endPoint = _proxy.Run().First(); From a93758edb85f442b33ac6a8e0f51996c412b312c Mon Sep 17 00:00:00 2001 From: haga-rak Date: Fri, 10 Apr 2026 01:51:35 +0200 Subject: [PATCH 03/26] Use ThreadPool for pipe --- src/Fluxzy.Core/Clients/H2/StreamWorker.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Fluxzy.Core/Clients/H2/StreamWorker.cs b/src/Fluxzy.Core/Clients/H2/StreamWorker.cs index 5375a129..2c9b3da5 100644 --- a/src/Fluxzy.Core/Clients/H2/StreamWorker.cs +++ b/src/Fluxzy.Core/Clients/H2/StreamWorker.cs @@ -56,7 +56,7 @@ public StreamWorker( _pipeResponseBody = new Pipe(new PipeOptions( pool: MemoryPool.Shared, - readerScheduler: PipeScheduler.Inline, + readerScheduler: PipeScheduler.ThreadPool, writerScheduler: PipeScheduler.Inline, pauseWriterThreshold: 0, resumeWriterThreshold: 0, From ea6c499ee1573be7263c90725be5a018d27847ab Mon Sep 17 00:00:00 2001 From: Haga Rak Date: Fri, 10 Apr 2026 12:17:24 +0200 Subject: [PATCH 04/26] Remove _headerEncodeLock by moving HPACK encode to WriteLoop HPACK dynamic-table mutation must be serialized per H2 connection, and the single lock that enforced this dominated blocked-wait time under H2 proxy load (99.5% of contention, ~30s of wait per 13s capture in the 0-byte body benchmark at 56 concurrent streams). Instead of locking, hand off encoding to the single-threaded WriteLoop, which already owns the wire. WriteResponseHeader becomes a fire-and- forget enqueue onto a new PendingHeaderWrite channel; the WriteLoop drains it into the ring buffer, with no synchronization around the shared HPACK state. Trailers take the same path: DataFrameEntry gains an inline trailer-job variant that the WriteLoop encodes on the fly, keeping per-stream wire order (trailers after DATA) via the existing FIFO channel. Phase 2 of the WriteLoop re-drains pending headers at the top of each data iteration to guarantee the HEADERS frame for a stream always precedes that stream's first DATA frame on the wire, even when a header is enqueued while data for another stream is being written. --- src/Fluxzy.Core/Core/H2DownStreamPipe.cs | 159 +++++++++++++++++------ 1 file changed, 121 insertions(+), 38 deletions(-) diff --git a/src/Fluxzy.Core/Core/H2DownStreamPipe.cs b/src/Fluxzy.Core/Core/H2DownStreamPipe.cs index dcf9d23b..c959d55b 100644 --- a/src/Fluxzy.Core/Core/H2DownStreamPipe.cs +++ b/src/Fluxzy.Core/Core/H2DownStreamPipe.cs @@ -20,15 +20,42 @@ namespace Fluxzy.Core { internal readonly struct DataFrameEntry { - public readonly byte[] RentedBuffer; + public readonly byte[]? RentedBuffer; public readonly int Length; public readonly int FlowControlledBytes; + public readonly IList? TrailerHeaders; + public readonly int TrailerStreamIdentifier; public DataFrameEntry(byte[] rentedBuffer, int length, int flowControlledBytes) { RentedBuffer = rentedBuffer; Length = length; FlowControlledBytes = flowControlledBytes; + TrailerHeaders = null; + TrailerStreamIdentifier = 0; + } + + public DataFrameEntry(IList trailerHeaders, int trailerStreamIdentifier) + { + RentedBuffer = null; + Length = 0; + FlowControlledBytes = 0; + TrailerHeaders = trailerHeaders; + TrailerStreamIdentifier = trailerStreamIdentifier; + } + } + + internal readonly struct PendingHeaderWrite + { + public readonly ReadOnlyMemory Http11Header; + public readonly int StreamIdentifier; + public readonly bool HasBody; + + public PendingHeaderWrite(ReadOnlyMemory http11Header, int streamIdentifier, bool hasBody) + { + Http11Header = http11Header; + StreamIdentifier = streamIdentifier; + HasBody = hasBody; } } @@ -48,7 +75,10 @@ internal class H2DownStreamPipe : IDownStreamPipe private readonly ConcurrentDictionary _currentStreams = new(); private readonly HeaderEncoder _headerEncoder; - private readonly object _headerEncodeLock = new(); + private readonly Channel _pendingHeaders = + Channel.CreateUnbounded( + new UnboundedChannelOptions() { SingleReader = true }); + private readonly RsBuffer _headerEncodeBuffer = RsBuffer.Allocate(16 * 1024); private readonly H2StreamSetting _h2StreamSetting = new H2StreamSetting() { Local = new () { SettingsMaxConcurrentStreams = 256 @@ -408,7 +438,15 @@ private async Task WriteLoop(CancellationToken token) while (!token.IsCancellationRequested) { var didWork = false; - // Phase 1: Drain ring buffer (control frames, HEADERS — priority, no flow control) + // Phase 1: Drain pending header encodes into the ring buffer, then drain + // the ring buffer (control frames + HEADERS — priority, no flow control). + // Encoding runs only here, so the shared HPACK dynamic table needs + // no synchronization. + while (_pendingHeaders.Reader.TryRead(out var pending)) { + EncodePendingHeader(pending); + didWork = true; + } + _ringBuffer.GetReadableRegions(out var seg1, out var seg2, out var total); if (total > 0) { @@ -428,6 +466,14 @@ private async Task WriteLoop(CancellationToken token) var gatherOffset = 0; while (_dataChannel.Reader.TryPeek(out var entry)) { + // Re-drain any pending headers that arrived during data writes so the + // HEADERS frame for stream X always precedes its DATA on the wire. + // Headers go into the ring buffer, which the interleave block below flushes. + while (_pendingHeaders.Reader.TryRead(out var pending)) { + EncodePendingHeader(pending); + didWork = true; + } + // Interleave: flush gathered data and drain ring buffer if priority data exists if (_ringBuffer.ReadableCount > 0) { if (gatherOffset > 0) { @@ -450,6 +496,23 @@ private async Task WriteLoop(CancellationToken token) } } + // Trailer-encoding job (placed inline in the DATA channel to preserve + // per-stream ordering relative to the DATA frames queued ahead of it). + if (entry.TrailerHeaders != null) { + if (gatherOffset > 0) { + await _writeStream.WriteAsync(gatherBuffer!.AsMemory(0, gatherOffset), token).ConfigureAwait(false); + gatherOffset = 0; + } + + var trailerBytes = _headerEncoder.EncodeTrailers( + entry.TrailerHeaders, _headerEncodeBuffer, entry.TrailerStreamIdentifier); + + await _writeStream.WriteAsync(trailerBytes, token).ConfigureAwait(false); + _dataChannel.Reader.TryRead(out _); // consume the peeked entry + didWork = true; + continue; + } + if (entry.FlowControlledBytes > 0) { var window = Volatile.Read(ref _connectionWindow); @@ -464,8 +527,8 @@ private async Task WriteLoop(CancellationToken token) // Single frame with nothing else queued — write directly, skip gather if (gatherOffset == 0 && !_dataChannel.Reader.TryPeek(out _)) { await _writeStream.WriteAsync( - entry.RentedBuffer.AsMemory(0, entry.Length), token).ConfigureAwait(false); - ArrayPool.Shared.Return(entry.RentedBuffer); + entry.RentedBuffer!.AsMemory(0, entry.Length), token).ConfigureAwait(false); + ArrayPool.Shared.Return(entry.RentedBuffer!); didWork = true; break; } @@ -482,9 +545,9 @@ await _writeStream.WriteAsync( } } - entry.RentedBuffer.AsSpan(0, entry.Length).CopyTo(gatherBuffer.AsSpan(gatherOffset)); + entry.RentedBuffer!.AsSpan(0, entry.Length).CopyTo(gatherBuffer.AsSpan(gatherOffset)); gatherOffset += entry.Length; - ArrayPool.Shared.Return(entry.RentedBuffer); + ArrayPool.Shared.Return(entry.RentedBuffer!); didWork = true; } @@ -511,15 +574,22 @@ await _writeStream.WriteAsync( if (_dataChannel.Reader.TryPeek(out _)) continue; - // Check termination: both sources completed and empty - if (_ringBuffer.IsCompleted && _dataChannel.Reader.Completion.IsCompleted) { + if (_pendingHeaders.Reader.TryPeek(out _)) + continue; + + // Check termination: all sources completed and empty + if (_ringBuffer.IsCompleted && + _dataChannel.Reader.Completion.IsCompleted && + _pendingHeaders.Reader.Completion.IsCompleted) { // Final drain to catch any data that arrived between checks - if (_ringBuffer.ReadableCount > 0 || _dataChannel.Reader.TryPeek(out _)) + if (_ringBuffer.ReadableCount > 0 || + _dataChannel.Reader.TryPeek(out _) || + _pendingHeaders.Reader.TryPeek(out _)) continue; break; } - + await _writeSignal.WaitAsync(token).ConfigureAwait(false); } } @@ -531,8 +601,13 @@ await _writeStream.WriteAsync( } finally { // Return rented buffers from any remaining channel entries - while (_dataChannel.Reader.TryRead(out var remaining)) - ArrayPool.Shared.Return(remaining.RentedBuffer); + while (_dataChannel.Reader.TryRead(out var remaining)) { + if (remaining.RentedBuffer != null) + ArrayPool.Shared.Return(remaining.RentedBuffer); + } + + // Drain leftover pending header jobs (they hold only managed memory). + while (_pendingHeaders.Reader.TryRead(out _)) { } _writeHalted = true; } @@ -559,27 +634,40 @@ await _writeStream.WriteAsync( } } - public ValueTask WriteResponseHeader( - ResponseHeader responseHeader, RsBuffer buffer, bool shouldClose, int streamIdentifier, ReadOnlyMemory requestMethod, CancellationToken token) + /// + /// HPACK-encode a pending response header and commit the bytes to the ring buffer. + /// Called only from the single-threaded WriteLoop, so no synchronization around the + /// shared HPACK dynamic table is required. + /// + private void EncodePendingHeader(in PendingHeaderWrite pending) { - var hasBody = responseHeader.HasResponseBody(requestMethod.Span, out _); - ReadOnlyMemory payload; - - lock (_headerEncodeLock) { - payload = _headerEncoder.Encode( - new HeaderEncodingJob(responseHeader.GetHttp11Header(), - streamIdentifier, 0), - buffer, !hasBody); - } + var encoded = _headerEncoder.Encode( + new HeaderEncodingJob(pending.Http11Header, pending.StreamIdentifier, 0), + _headerEncodeBuffer, !pending.HasBody); - _ringBuffer.Write(payload.Span); + _ringBuffer.Write(encoded.Span); - if (!hasBody) { + if (!pending.HasBody) { // No body will follow — clean up the stream worker now. - if (_currentStreams.TryRemove(streamIdentifier, out var worker)) { + if (_currentStreams.TryRemove(pending.StreamIdentifier, out var worker)) { worker.Dispose(); } } + } + + public ValueTask WriteResponseHeader( + ResponseHeader responseHeader, RsBuffer buffer, bool shouldClose, int streamIdentifier, ReadOnlyMemory requestMethod, CancellationToken token) + { + // Compute hasBody on the caller thread (needs requestMethod.Span) and materialize the + // HTTP/1.1 header representation here — GetHttp11Header() is a pure, fresh allocation + // so it's safe to hand off. Actual HPACK encoding happens on the WriteLoop, which is + // the sole owner of the shared HPACK dynamic table: no lock required on the hot path. + var hasBody = responseHeader.HasResponseBody(requestMethod.Span, out _); + + _pendingHeaders.Writer.TryWrite(new PendingHeaderWrite( + responseHeader.GetHttp11Header(), streamIdentifier, hasBody)); + + SignalWriteLoop(); return default; } @@ -645,18 +733,11 @@ await _dataChannel.Writer.WriteAsync( var trailers = responseForTrailers?.Trailers; if (trailers != null && trailers.Count > 0) { - // Send trailers as HEADERS frame with EndStream, enqueue to data channel - ReadOnlyMemory payload; - - lock (_headerEncodeLock) { - payload = _headerEncoder.EncodeTrailers(trailers, rsBuffer, streamIdentifier); - } - - var rentedTrailerBuffer = ArrayPool.Shared.Rent(payload.Length); - payload.Span.CopyTo(rentedTrailerBuffer); - + // Enqueue a trailer-encoding job; WriteLoop encodes it on its own thread (no lock). + // Wire ordering is preserved because all DATA frames for this stream are already + // queued ahead of this entry in the same FIFO channel. await _dataChannel.Writer.WriteAsync( - new DataFrameEntry(rentedTrailerBuffer, payload.Length, 0), token).ConfigureAwait(false); + new DataFrameEntry(trailers, streamIdentifier), token).ConfigureAwait(false); SignalWriteLoop(); } @@ -703,6 +784,7 @@ public void Dispose() _mainLoopTokenSource.Cancel(); _ringBuffer.Complete(); _dataChannel.Writer.TryComplete(); + _pendingHeaders.Writer.TryComplete(); _exchangeChannel.Writer.TryComplete(); foreach (var (_, worker) in _currentStreams) @@ -711,6 +793,7 @@ public void Dispose() } _ringBuffer.Dispose(); + _headerEncodeBuffer.Dispose(); } } } From c8c0f189224f0c939392bb7712ce5111171941d9 Mon Sep 17 00:00:00 2001 From: Haga Rak Date: Fri, 10 Apr 2026 12:20:41 +0200 Subject: [PATCH 05/26] Update benchmark run settings --- benchmark-throughput.cmd | 14 +++++++++ benchmark-throughput.sh | 12 +++++++ .../ProxyThroughputBenchmark.cs | 31 +++++++++++++++++++ 3 files changed, 57 insertions(+) diff --git a/benchmark-throughput.cmd b/benchmark-throughput.cmd index 9d1ce715..4384e23c 100644 --- a/benchmark-throughput.cmd +++ b/benchmark-throughput.cmd @@ -11,6 +11,13 @@ if /i "%~1"=="--short" ( shift goto parse_args ) +if /i "%~1"=="--contention" ( + rem Opt-in CLR contention ETW trace (EventPipe). Produces .nettrace per run + rem in BenchmarkDotNet.Artifacts/. Open in PerfView / VS / speedscope. + set FLUXZY_BENCH_CONTENTION=1 + shift + goto parse_args +) if /i "%~1"=="--h2-8k" ( rem H2 + 8192 body only, ~30%% of default duration set SHORT_ARGS=--warmupCount 2 --iterationCount 5 --launchCount 1 @@ -18,6 +25,13 @@ if /i "%~1"=="--h2-8k" ( shift goto parse_args ) +if /i "%~1"=="--h2-0k" ( + rem H2 + 0 body only, ~30%% of default duration + set SHORT_ARGS=--warmupCount 2 --iterationCount 5 --launchCount 1 + set FILTER=*ProxyThroughputBenchmark*True*0* + shift + goto parse_args +) set FILTER=%~1 shift goto parse_args diff --git a/benchmark-throughput.sh b/benchmark-throughput.sh index 0165c606..c6985dbe 100755 --- a/benchmark-throughput.sh +++ b/benchmark-throughput.sh @@ -11,12 +11,24 @@ while [[ $# -gt 0 ]]; do SHORT_ARGS="--warmupCount 1 --iterationCount 3 --launchCount 1" shift ;; + --contention) + # Opt-in CLR contention ETW trace (EventPipe). Produces .nettrace per run + # in BenchmarkDotNet.Artifacts/. Open in PerfView / VS / speedscope. + export FLUXZY_BENCH_CONTENTION=1 + shift + ;; --h2-8k) # H2 + 8192 body only, ~30% of default duration SHORT_ARGS="--warmupCount 2 --iterationCount 5 --launchCount 1" FILTER="*ProxyThroughputBenchmark*True*8192*" shift ;; + --h2-0k) + # H2 + 0 body only, ~30% of default duration + SHORT_ARGS="--warmupCount 2 --iterationCount 5 --launchCount 1" + FILTER="*ProxyThroughputBenchmark*True*0*" + shift + ;; *) FILTER="$1" shift diff --git a/test/Fluxzy.Benchmarks/ProxyThroughputBenchmark.cs b/test/Fluxzy.Benchmarks/ProxyThroughputBenchmark.cs index 6efc35c1..20d4ccd8 100644 --- a/test/Fluxzy.Benchmarks/ProxyThroughputBenchmark.cs +++ b/test/Fluxzy.Benchmarks/ProxyThroughputBenchmark.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics.Tracing; using System.IO; using System.Linq; using System.Net; @@ -8,10 +9,12 @@ using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Columns; using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Diagnosers; using BenchmarkDotNet.Reports; using Fluxzy.Rules; using Fluxzy.Rules.Actions; using Fluxzy.Tests._Fixtures; +using Microsoft.Diagnostics.NETCore.Client; namespace Fluxzy.Benchmarks; @@ -128,10 +131,38 @@ private async Task SendRequest() private class Config : ManualConfig { + // CLR ETW keywords — values come from Microsoft-Windows-DotNETRuntime provider manifest. + // Combined, these give us ContentionStart/Stop with resolvable managed call stacks. + private const long ClrContentionKeyword = 0x4000; // GC=0x1, Loader=0x8, Jit=0x10, Contention=0x4000 + private const long ClrJitKeyword = 0x10; + private const long ClrLoaderKeyword = 0x8; + private const long ClrJitToNativeMapKeyword = 0x20000; + private const long ClrStackKeyword = 0x40000000; + public Config() { WithSummaryStyle(SummaryStyle.Default.WithRatioStyle(RatioStyle.Percentage)); AddColumn(StatisticColumn.OperationsPerSecond); + + // Opt-in contention trace: FLUXZY_BENCH_CONTENTION=1 produces a .nettrace per benchmark + // run (in BenchmarkDotNet.Artifacts/), openable in PerfView / VS / speedscope. + if (string.Equals( + Environment.GetEnvironmentVariable("FLUXZY_BENCH_CONTENTION"), + "1", + StringComparison.Ordinal)) { + var providers = new[] { + new EventPipeProvider( + name: "Microsoft-Windows-DotNETRuntime", + eventLevel: EventLevel.Verbose, + keywords: ClrContentionKeyword + | ClrJitKeyword + | ClrLoaderKeyword + | ClrJitToNativeMapKeyword + | ClrStackKeyword) + }; + + AddDiagnoser(new EventPipeProfiler(providers: providers)); + } } } } From 5b04b9b5cde97d86d380b0bb60b01dd72d51f9c0 Mon Sep 17 00:00:00 2001 From: Haga Rak Date: Fri, 10 Apr 2026 19:04:54 +0200 Subject: [PATCH 06/26] Remove useless await --- src/Fluxzy.Core/Core/H2DownStreamPipe.cs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/Fluxzy.Core/Core/H2DownStreamPipe.cs b/src/Fluxzy.Core/Core/H2DownStreamPipe.cs index c959d55b..418e2e65 100644 --- a/src/Fluxzy.Core/Core/H2DownStreamPipe.cs +++ b/src/Fluxzy.Core/Core/H2DownStreamPipe.cs @@ -723,8 +723,7 @@ public async ValueTask WriteResponseBody(Stream responseBodyStream, var frameLength = read + 9; - await _dataChannel.Writer.WriteAsync( - new DataFrameEntry(rentedBuffer, frameLength, read), token).ConfigureAwait(false); + _dataChannel.Writer.TryWrite(new DataFrameEntry(rentedBuffer, frameLength, read)); SignalWriteLoop(); } @@ -736,8 +735,7 @@ await _dataChannel.Writer.WriteAsync( // Enqueue a trailer-encoding job; WriteLoop encodes it on its own thread (no lock). // Wire ordering is preserved because all DATA frames for this stream are already // queued ahead of this entry in the same FIFO channel. - await _dataChannel.Writer.WriteAsync( - new DataFrameEntry(trailers, streamIdentifier), token).ConfigureAwait(false); + _dataChannel.Writer.TryWrite(new DataFrameEntry(trailers, streamIdentifier)); SignalWriteLoop(); } @@ -748,8 +746,7 @@ await _dataChannel.Writer.WriteAsync( new DataFrame(HeaderFlags.EndStream, 0, streamIdentifier) .WriteHeaderOnly(endFrameBuffer, 0); - await _dataChannel.Writer.WriteAsync( - new DataFrameEntry(endFrameBuffer, 9, 0), token).ConfigureAwait(false); + _dataChannel.Writer.TryWrite(new DataFrameEntry(endFrameBuffer, 9, 0)); SignalWriteLoop(); } From afd0bdcd1dbb28e60cfd90238e61b843f5850188 Mon Sep 17 00:00:00 2001 From: Haga Rak Date: Sat, 11 Apr 2026 01:15:17 +0200 Subject: [PATCH 07/26] Optimize http11 parser hot path --- .../Clients/H2/Encoder/HPackEncoder.cs | 9 +- .../H2/Encoder/Utils/Http11HeaderReader.cs | 384 ++++++++++++++++++ .../Clients/H2/Encoder/Utils/Http11Parser.cs | 86 +--- .../HeaderEncoderBenchmark.cs | 117 ++++++ .../UnitTests/HPack/Http11ParserTests.cs | 219 ++++++++++ 5 files changed, 730 insertions(+), 85 deletions(-) create mode 100644 src/Fluxzy.Core/Clients/H2/Encoder/Utils/Http11HeaderReader.cs create mode 100644 test/Fluxzy.Benchmarks/HeaderEncoderBenchmark.cs diff --git a/src/Fluxzy.Core/Clients/H2/Encoder/HPackEncoder.cs b/src/Fluxzy.Core/Clients/H2/Encoder/HPackEncoder.cs index 52b1b048..6e538129 100644 --- a/src/Fluxzy.Core/Clients/H2/Encoder/HPackEncoder.cs +++ b/src/Fluxzy.Core/Clients/H2/Encoder/HPackEncoder.cs @@ -42,8 +42,13 @@ public ReadOnlySpan Encode(ReadOnlyMemory headerContent, Span { var offset = 0; - foreach (var headerField in Http11Parser.Read(headerContent, isHttps)) { - offset += Encode(headerField, buffer.Slice(offset)); + // Hot path: stream parse + encode in a single pass via a ref-struct enumerator, + // avoiding the List and iterator-state-machine allocations that used + // to dominate the per-request cost. + var reader = new Http11HeaderReader(headerContent, isHttps); + + while (reader.MoveNext()) { + offset += Encode(reader.Current, buffer.Slice(offset)); } return buffer.Slice(0, offset); diff --git a/src/Fluxzy.Core/Clients/H2/Encoder/Utils/Http11HeaderReader.cs b/src/Fluxzy.Core/Clients/H2/Encoder/Utils/Http11HeaderReader.cs new file mode 100644 index 00000000..c10751e2 --- /dev/null +++ b/src/Fluxzy.Core/Clients/H2/Encoder/Utils/Http11HeaderReader.cs @@ -0,0 +1,384 @@ +// Copyright 2021 - Haga Rakotoharivelo - https://github.com/haga-rak + +using System; +using Fluxzy.Clients.H2.Encoder.HPack; + +namespace Fluxzy.Clients.H2.Encoder.Utils +{ + /// + /// Allocation-free, forward-only reader that walks an HTTP/1.1 header block and + /// yields HTTP/2-compatible entries. + /// + /// Replaces the list-and-iterator pair used by the previous + /// implementation on the hot HPACK encoding path. + /// + internal ref struct Http11HeaderReader + { + private readonly ReadOnlyMemory _source; + private readonly bool _keepNonForwardableHeader; + private readonly bool _splitCookies; + + // Offset into _source where line parsing resumes. + private int _sourcePos; + + // Pseudo-headers produced from the first line (request: method/scheme/path; response: status). + private int _pendingCount; + private int _pendingIndex; + private HeaderField _pending0; + private HeaderField _pending1; + private HeaderField _pending2; + + // Mid-cookie split state: offsets into _source of the remaining cookie value. + private int _cookieRemainingStart; + private int _cookieRemainingEnd; + private bool _splittingCookie; + + public Http11HeaderReader( + ReadOnlyMemory source, + bool isHttps = true, + bool keepNonForwardableHeader = false, + bool splitCookies = true) + { + _source = source; + _keepNonForwardableHeader = keepNonForwardableHeader; + _splitCookies = splitCookies; + _sourcePos = 0; + _pendingIndex = 0; + _pendingCount = 0; + _pending0 = default; + _pending1 = default; + _pending2 = default; + _cookieRemainingStart = 0; + _cookieRemainingEnd = 0; + _splittingCookie = false; + Current = default; + + ParseFirstLine(isHttps); + } + + public HeaderField Current { get; private set; } + + public readonly Http11HeaderReader GetEnumerator() => this; + + public bool MoveNext() + { + // 1) Serve any queued pseudo-headers produced by the first line. + if (_pendingIndex < _pendingCount) { + Current = _pendingIndex switch { + 0 => _pending0, + 1 => _pending1, + 2 => _pending2, + _ => default + }; + _pendingIndex++; + return true; + } + + // 2) Continue a cookie split if we paused mid-line on the previous call. + if (_splittingCookie) { + if (TryNextCookie(out var cookie)) { + Current = cookie; + return true; + } + + _splittingCookie = false; + } + + // 3) Walk remaining header lines. + var span = _source.Span; + + while (_sourcePos < span.Length) { + var lineStart = SkipEmptyLines(span, _sourcePos); + + if (lineStart >= span.Length) { + _sourcePos = span.Length; + break; + } + + var lineEnd = FindLineEnd(span, lineStart); + _sourcePos = lineEnd; + + if (lineStart >= lineEnd) + continue; + + if (TryEmitHeaderLine(span, lineStart, lineEnd, out var field)) { + Current = field; + return true; + } + } + + Current = default; + return false; + } + + private bool TryEmitHeaderLine( + ReadOnlySpan span, int lineStart, int lineEnd, out HeaderField field) + { + var lineLength = lineEnd - lineStart; + var lineSpan = span.Slice(lineStart, lineLength); + + var colonRelative = lineSpan.IndexOf(':'); + + if (colonRelative < 0) { + throw new HPackCodecException( + $"Invalid header on line {_source.Slice(lineStart, lineLength)}"); + } + + var nameStart = lineStart; + var nameEnd = lineStart + colonRelative; + + // Trim spaces around the name. HTTP/1.1 does not permit whitespace in a field-name, + // but we stay lenient to preserve the previous parser's tolerance. + while (nameStart < nameEnd && span[nameStart] == ' ') + nameStart++; + + while (nameEnd > nameStart && span[nameEnd - 1] == ' ') + nameEnd--; + + if (nameEnd == nameStart) { + throw new HPackCodecException( + $"Invalid header on line {_source.Slice(lineStart, lineLength)}"); + } + + var valueStart = lineStart + colonRelative + 1; + var valueEnd = lineEnd; + + // Trim spaces (OWS) around the value. Matches the previous Trim(' ') behavior. + while (valueStart < valueEnd && span[valueStart] == ' ') + valueStart++; + + while (valueEnd > valueStart && span[valueEnd - 1] == ' ') + valueEnd--; + + var nameSpan = span.Slice(nameStart, nameEnd - nameStart); + + if (!_keepNonForwardableHeader && IsNonForwardable(nameSpan)) { + field = default; + return false; + } + + // Host → :authority + if (nameSpan.Equals(Http11Constants.HostVerb.Span, StringComparison.OrdinalIgnoreCase)) { + field = new HeaderField( + Http11Constants.AuthorityVerb, + _source.Slice(valueStart, valueEnd - valueStart)); + + return true; + } + + // Cookie → optionally split on ';' + if (_splitCookies && + nameSpan.Equals(Http11Constants.CookieVerb.Span, StringComparison.OrdinalIgnoreCase)) { + _cookieRemainingStart = valueStart; + _cookieRemainingEnd = valueEnd; + _splittingCookie = true; + + if (TryNextCookie(out field)) + return true; + + _splittingCookie = false; + field = default; + + return false; + } + + field = new HeaderField( + _source.Slice(nameStart, nameEnd - nameStart), + _source.Slice(valueStart, valueEnd - valueStart)); + + return true; + } + + private bool TryNextCookie(out HeaderField cookie) + { + var span = _source.Span; + + while (_cookieRemainingStart < _cookieRemainingEnd) { + // Skip leading separators/whitespace between cookies. + while (_cookieRemainingStart < _cookieRemainingEnd && + (span[_cookieRemainingStart] == ' ' || + span[_cookieRemainingStart] == ';')) { + _cookieRemainingStart++; + } + + if (_cookieRemainingStart >= _cookieRemainingEnd) + break; + + var entryStart = _cookieRemainingStart; + var remainingSlice = span.Slice(entryStart, _cookieRemainingEnd - entryStart); + var sepRelative = remainingSlice.IndexOf(';'); + + int entryEnd; + + if (sepRelative < 0) { + entryEnd = _cookieRemainingEnd; + _cookieRemainingStart = _cookieRemainingEnd; + } + else { + entryEnd = entryStart + sepRelative; + _cookieRemainingStart = entryEnd + 1; + } + + // Trim trailing spaces. + while (entryEnd > entryStart && span[entryEnd - 1] == ' ') + entryEnd--; + + if (entryEnd > entryStart) { + cookie = new HeaderField( + Http11Constants.CookieVerb, + _source.Slice(entryStart, entryEnd - entryStart)); + + return true; + } + } + + cookie = default; + + return false; + } + + private void ParseFirstLine(bool isHttps) + { + var span = _source.Span; + + if (span.IsEmpty) + return; + + var lineStart = SkipEmptyLines(span, 0); + + if (lineStart >= span.Length) { + _sourcePos = span.Length; + + return; + } + + var lineEnd = FindLineEnd(span, lineStart); + _sourcePos = lineEnd; + + if (lineStart >= lineEnd) + return; + + var line = _source.Slice(lineStart, lineEnd - lineStart); + var lineSpan = line.Span; + + // Response header block starts with "HTTP" (e.g. "HTTP/1.1 200 OK"). + if (lineSpan.Length >= 4 && + lineSpan.Slice(0, 4).Equals("HTTP".AsSpan(), StringComparison.OrdinalIgnoreCase)) { + ParseResponseStatus(line); + + return; + } + + ParseRequestLine(line, isHttps); + } + + private void ParseRequestLine(ReadOnlyMemory line, bool isHttps) + { + var lineSpan = line.Span; + + var firstSpace = lineSpan.IndexOfAny(' ', '\t'); + + if (firstSpace <= 0) + return; + + var pathStart = firstSpace + 1; + + while (pathStart < lineSpan.Length && + (lineSpan[pathStart] == ' ' || lineSpan[pathStart] == '\t')) { + pathStart++; + } + + if (pathStart >= lineSpan.Length) + return; + + var tail = lineSpan.Slice(pathStart); + var pathTerminator = tail.IndexOfAny(' ', '\t'); + var pathLength = pathTerminator < 0 ? tail.Length : pathTerminator; + + if (pathLength == 0) + return; + + var methodMem = line.Slice(0, firstSpace); + var pathMem = line.Slice(pathStart, pathLength).RemoveProtocolAndAuthority(); + + _pending0 = new HeaderField(Http11Constants.MethodVerb, methodMem); + _pending1 = new HeaderField( + Http11Constants.SchemeVerb, + isHttps ? Http11Constants.HttpsVerb : Http11Constants.HttpVerb); + _pending2 = new HeaderField(Http11Constants.PathVerb, pathMem); + _pendingCount = 3; + } + + private void ParseResponseStatus(ReadOnlyMemory line) + { + var lineSpan = line.Span; + + var firstSpace = lineSpan.IndexOfAny(' ', '\t'); + + if (firstSpace < 0) + return; + + var statusStart = firstSpace + 1; + + while (statusStart < lineSpan.Length && + (lineSpan[statusStart] == ' ' || lineSpan[statusStart] == '\t')) { + statusStart++; + } + + if (statusStart >= lineSpan.Length) + return; + + var tail = lineSpan.Slice(statusStart); + var statusTerminator = tail.IndexOfAny(' ', '\t'); + var statusLength = statusTerminator < 0 ? tail.Length : statusTerminator; + + if (statusLength == 0) + return; + + _pending0 = new HeaderField( + Http11Constants.StatusVerb, + line.Slice(statusStart, statusLength)); + + _pendingCount = 1; + } + + private static int SkipEmptyLines(ReadOnlySpan span, int from) + { + while (from < span.Length && (span[from] == '\r' || span[from] == '\n')) + from++; + + return from; + } + + private static int FindLineEnd(ReadOnlySpan span, int from) + { + if (from >= span.Length) + return span.Length; + + var relative = span.Slice(from).IndexOfAny('\r', '\n'); + + return relative < 0 ? span.Length : from + relative; + } + + /// + /// Inlined lookup for the small NonH2Header set. Avoids the per-call hashset hash + /// cost that dominates the previous parser on the hot encode path. + /// + private static bool IsNonForwardable(ReadOnlySpan name) + { + // Known names (case-insensitive): connection, keep-alive, proxy-authenticate, + // trailer, upgrade, alt-svc, expect, x-fluxzy-live-edit. + return name.Length switch { + 6 => name.Equals("expect", StringComparison.OrdinalIgnoreCase), + 7 => name.Equals("trailer", StringComparison.OrdinalIgnoreCase) + || name.Equals("upgrade", StringComparison.OrdinalIgnoreCase) + || name.Equals("alt-svc", StringComparison.OrdinalIgnoreCase), + 10 => name.Equals("connection", StringComparison.OrdinalIgnoreCase) + || name.Equals("keep-alive", StringComparison.OrdinalIgnoreCase), + 18 => name.Equals("proxy-authenticate", StringComparison.OrdinalIgnoreCase) + || name.Equals("x-fluxzy-live-edit", StringComparison.OrdinalIgnoreCase), + _ => false + }; + } + } +} diff --git a/src/Fluxzy.Core/Clients/H2/Encoder/Utils/Http11Parser.cs b/src/Fluxzy.Core/Clients/H2/Encoder/Utils/Http11Parser.cs index 8950e5a4..bf6b213b 100644 --- a/src/Fluxzy.Core/Clients/H2/Encoder/Utils/Http11Parser.cs +++ b/src/Fluxzy.Core/Clients/H2/Encoder/Utils/Http11Parser.cs @@ -19,90 +19,10 @@ public static List Read( bool splitCookies = true) { var result = new List(); - var firstLine = true; + var reader = new Http11HeaderReader(input, isHttps, keepNonForwardableHeader, splitCookies); - foreach (var line in input.Split(Http11Constants.LineSeparators)) { - if (firstLine) { - // parsing request line — need indexed access, extract parts manually - var part0 = ReadOnlyMemory.Empty; - var part1 = ReadOnlyMemory.Empty; - var partCount = 0; - - foreach (var part in line.Split(Http11Constants.SpaceSeparators, 3)) { - if (partCount == 0) part0 = part; - else if (partCount == 1) part1 = part; - partCount++; - } - - if (partCount >= 2) { - if (part0.Length >= 4 - && part0.Slice(0, 4).Span - .Equals("HTTP".AsSpan(), StringComparison.OrdinalIgnoreCase)) { - // Response header block - - result.Add(new HeaderField(Http11Constants.StatusVerb, part1)); - } - else { - // Request header block - - result.Add(new HeaderField(Http11Constants.MethodVerb, part0)); - - result.Add(new HeaderField(Http11Constants.SchemeVerb, - isHttps ? Http11Constants.HttpsVerb : Http11Constants.HttpVerb)); - - result.Add(new HeaderField(Http11Constants.PathVerb, - part1.RemoveProtocolAndAuthority())); // Remove prefix on path - - - if (Http11Constants.SchemeVerb.Span.StartsWith(Http11Constants.HttpsVerb.Span)) - isHttps = true; - } - } - - firstLine = false; - - continue; - } - - // Header line — split into name:value (max 2 parts) - var kName = ReadOnlyMemory.Empty; - var kValue = ReadOnlyMemory.Empty; - var kvCount = 0; - - foreach (var part in line.Split(Http11Constants.HeaderSeparator, 2)) { - if (kvCount == 0) kName = part; - else kValue = part; - kvCount++; - } - - if (kvCount != 2) - throw new HPackCodecException($"Invalid header on line {line}"); - - var headerName = kName.Trim(); // should we trim here? - - if (!keepNonForwardableHeader && Http11Constants.NonH2Header.Contains(headerName)) - continue; - - var headerValue = kValue.Trim(); - - if (headerName.Span.Equals(Http11Constants.HostVerb.Span, StringComparison.OrdinalIgnoreCase)) { - result.Add(new HeaderField(Http11Constants.AuthorityVerb, headerValue)); - - continue; - } - - if (headerName.Span.Equals(Http11Constants.CookieVerb.Span, StringComparison.OrdinalIgnoreCase)) { - if (splitCookies) { - foreach (var cookieEntry in headerValue.Split(Http11Constants.CookieSeparators)) { - result.Add(new HeaderField(Http11Constants.CookieVerb, cookieEntry.Trim())); - } - - continue; - } - } - - result.Add(new HeaderField(headerName, headerValue)); - } + while (reader.MoveNext()) + result.Add(reader.Current); return result; } diff --git a/test/Fluxzy.Benchmarks/HeaderEncoderBenchmark.cs b/test/Fluxzy.Benchmarks/HeaderEncoderBenchmark.cs new file mode 100644 index 00000000..03d587f5 --- /dev/null +++ b/test/Fluxzy.Benchmarks/HeaderEncoderBenchmark.cs @@ -0,0 +1,117 @@ +using System; +using BenchmarkDotNet.Attributes; +using Fluxzy.Clients.H2; +using Fluxzy.Clients.H2.Encoder; +using Fluxzy.Clients.H2.Encoder.Utils; +using Fluxzy.Core; +using Fluxzy.Misc.ResizableBuffers; + +namespace Fluxzy.Benchmarks; + +/// +/// Measures HeaderEncoder.Encode() — the per-request path that turns an +/// HTTP/1.1 header block into HPACK-encoded HEADERS frame(s). +/// Runs on the H2 WriteLoop for every forwarded request. +/// +/// Run: dotnet run -c Release -- --filter *HeaderEncoder* +/// +[MemoryDiagnoser] +public class HeaderEncoderBenchmark +{ + // ~40 B — minimal GET. + private const string SmallHeaders = + "GET / HTTP/1.1\r\n" + + "Host: example.com\r\n" + + "\r\n"; + + // ~400 B — typical API request. + private const string TypicalHeaders = + "GET /api/users/42 HTTP/1.1\r\n" + + "Host: api.example.com\r\n" + + "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36\r\n" + + "Accept: application/json, text/plain, */*\r\n" + + "Accept-Language: en-US,en;q=0.9\r\n" + + "Accept-Encoding: gzip, deflate, br\r\n" + + "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.payload.sig\r\n" + + "Referer: https://www.example.com/\r\n" + + "Connection: keep-alive\r\n" + + "\r\n"; + + // ~1.2 kB — heavy browser navigation with cookies and sec-ch-ua. + private const string LargeHeaders = + "GET /complex/path/with/segments?a=1&b=2 HTTP/1.1\r\n" + + "Host: www.example.com\r\n" + + "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\r\n" + + "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8\r\n" + + "Accept-Language: en-US,en;q=0.9,fr;q=0.8\r\n" + + "Accept-Encoding: gzip, deflate, br, zstd\r\n" + + "Cookie: session=abcd1234efgh5678ijkl; csrftoken=xyzpdq7890abcdef; theme=dark; lang=en-US; trackingId=9f8e7d6c5b4a3928; utm_source=google; utm_campaign=spring_sale; _ga=GA1.2.123456789.1700000000; _gid=GA1.2.987654321.1700000000\r\n" + + "Referer: https://www.google.com/search?q=example+query&oq=example+query\r\n" + + "Sec-Ch-Ua: \"Not_A Brand\";v=\"8\", \"Chromium\";v=\"120\", \"Google Chrome\";v=\"120\"\r\n" + + "Sec-Ch-Ua-Mobile: ?0\r\n" + + "Sec-Ch-Ua-Platform: \"Windows\"\r\n" + + "Sec-Fetch-Dest: document\r\n" + + "Sec-Fetch-Mode: navigate\r\n" + + "Sec-Fetch-Site: cross-site\r\n" + + "Sec-Fetch-User: ?1\r\n" + + "Upgrade-Insecure-Requests: 1\r\n" + + "Cache-Control: max-age=0\r\n" + + "Connection: keep-alive\r\n" + + "\r\n"; + + public enum HeaderPayload + { + Small, + Typical, + Large + } + + private HeaderEncoder _encoder = null!; + private RsBuffer _destination = null!; + private ReadOnlyMemory _payload; + private int _streamId; + + [Params(HeaderPayload.Small, HeaderPayload.Typical, HeaderPayload.Large)] + public HeaderPayload Payload { get; set; } + + [GlobalSetup] + public void Setup() + { + var setting = new H2StreamSetting(); + var memoryProvider = ArrayPoolMemoryProvider.Default; + var hPackEncoder = new HPackEncoder(new EncodingContext(memoryProvider)); + var hPackDecoder = new HPackDecoder( + new DecodingContext(new Authority("bench.local", 443, true), memoryProvider)); + + _encoder = new HeaderEncoder(hPackEncoder, hPackDecoder, setting); + _destination = RsBuffer.Allocate(16 * 1024); + + _payload = Payload switch { + HeaderPayload.Small => SmallHeaders.AsMemory(), + HeaderPayload.Typical => TypicalHeaders.AsMemory(), + HeaderPayload.Large => LargeHeaders.AsMemory(), + _ => throw new InvalidOperationException() + }; + + // Prime the HPACK dynamic table so we measure steady-state encoding + // (long-lived H2 connections reuse the same encoder across many requests). + var priming = new HeaderEncodingJob(_payload, 1, 0); + _encoder.Encode(priming, _destination, endStream: true); + _streamId = 3; + } + + [GlobalCleanup] + public void Cleanup() + { + _destination.Dispose(); + } + + [Benchmark] + public int Encode() + { + var job = new HeaderEncodingJob(_payload, _streamId, 0); + _streamId += 2; + var result = _encoder.Encode(job, _destination, endStream: true); + return result.Length; + } +} diff --git a/test/Fluxzy.Tests/UnitTests/HPack/Http11ParserTests.cs b/test/Fluxzy.Tests/UnitTests/HPack/Http11ParserTests.cs index 083605f9..85e3604e 100644 --- a/test/Fluxzy.Tests/UnitTests/HPack/Http11ParserTests.cs +++ b/test/Fluxzy.Tests/UnitTests/HPack/Http11ParserTests.cs @@ -1,8 +1,11 @@ // Copyright 2021 - Haga Rakotoharivelo - https://github.com/haga-rak using System; +using System.Collections.Generic; using System.Linq; using System.Text; +using Fluxzy.Clients.H2.Encoder; +using Fluxzy.Clients.H2.Encoder.HPack; using Fluxzy.Clients.H2.Encoder.Utils; using Fluxzy.Tests._Files; using Xunit; @@ -38,5 +41,221 @@ public void Parse_Unparse_Response_Header() Assert.Equal(header, result, StringComparer.OrdinalIgnoreCase); } + + [Fact] + public void Read_Request_ExpandsPseudoHeadersAndAuthority() + { + var header = "GET /foo HTTP/1.1\r\nHost: example.com\r\nAccept: text/html\r\n\r\n"; + + var fields = Http11Parser.Read(header.AsMemory()); + + Assert.Collection( + fields, + f => AssertHeader(f, ":method", "GET"), + f => AssertHeader(f, ":scheme", "https"), + f => AssertHeader(f, ":path", "/foo"), + f => AssertHeader(f, ":authority", "example.com"), + f => AssertHeader(f, "Accept", "text/html")); + } + + [Fact] + public void Read_Request_SchemeHonoursIsHttpsFlag() + { + var header = "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n"; + + var fields = Http11Parser.Read(header.AsMemory(), isHttps: false); + + var scheme = fields.Single(f => + f.Name.Span.Equals(":scheme", StringComparison.Ordinal)); + + Assert.Equal("http", scheme.Value.ToString()); + } + + [Fact] + public void Read_Response_ExtractsStatusOnly() + { + var header = "HTTP/1.1 204 No Content\r\nX-Custom: abc\r\n\r\n"; + + var fields = Http11Parser.Read(header.AsMemory()); + + Assert.Collection( + fields, + f => AssertHeader(f, ":status", "204"), + f => AssertHeader(f, "X-Custom", "abc")); + } + + [Fact] + public void Read_Request_WithAbsoluteUriStripsSchemeAndAuthority() + { + var header = "GET https://example.com/foo/bar?x=1 HTTP/1.1\r\nHost: example.com\r\n\r\n"; + + var fields = Http11Parser.Read(header.AsMemory()); + + var path = fields.Single(f => + f.Name.Span.Equals(":path", StringComparison.Ordinal)); + + Assert.Equal("/foo/bar?x=1", path.Value.ToString()); + } + + [Fact] + public void Read_Request_SplitsCookiesByDefault() + { + var header = + "GET / HTTP/1.1\r\n" + + "Host: example.com\r\n" + + "Cookie: a=1; b=2; c=3\r\n\r\n"; + + var fields = Http11Parser.Read(header.AsMemory()); + + var cookieValues = fields + .Where(f => f.Name.Span.Equals("cookie", StringComparison.Ordinal)) + .Select(f => f.Value.ToString()) + .ToArray(); + + Assert.Equal(new[] { "a=1", "b=2", "c=3" }, cookieValues); + } + + [Fact] + public void Read_Request_KeepsCookieJoinedWhenSplitDisabled() + { + var header = + "GET / HTTP/1.1\r\n" + + "Host: example.com\r\n" + + "Cookie: a=1; b=2; c=3\r\n\r\n"; + + var fields = Http11Parser.Read(header.AsMemory(), splitCookies: false); + + var cookieValues = fields + .Where(f => f.Name.Span.Equals("Cookie", StringComparison.Ordinal)) + .Select(f => f.Value.ToString()) + .ToArray(); + + Assert.Single(cookieValues); + Assert.Equal("a=1; b=2; c=3", cookieValues[0]); + } + + [Fact] + public void Read_DropsNonForwardableHeaders_ByDefault() + { + var header = + "GET / HTTP/1.1\r\n" + + "Host: example.com\r\n" + + "Connection: keep-alive\r\n" + + "Keep-Alive: timeout=5\r\n" + + "Upgrade: h2c\r\n" + + "Accept: text/html\r\n\r\n"; + + var fields = Http11Parser.Read(header.AsMemory()); + + Assert.DoesNotContain(fields, f => + f.Name.Span.Equals("Connection", StringComparison.OrdinalIgnoreCase)); + + Assert.DoesNotContain(fields, f => + f.Name.Span.Equals("Keep-Alive", StringComparison.OrdinalIgnoreCase)); + + Assert.DoesNotContain(fields, f => + f.Name.Span.Equals("Upgrade", StringComparison.OrdinalIgnoreCase)); + + Assert.Contains(fields, f => + f.Name.Span.Equals("Accept", StringComparison.Ordinal)); + } + + [Fact] + public void Read_KeepsNonForwardableHeaders_WhenRequested() + { + var header = + "GET / HTTP/1.1\r\n" + + "Host: example.com\r\n" + + "Connection: keep-alive\r\n\r\n"; + + var fields = Http11Parser.Read( + header.AsMemory(), + isHttps: true, + keepNonForwardableHeader: true); + + Assert.Contains(fields, f => + f.Name.Span.Equals("Connection", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void Read_ValueRetainsEmbeddedColons() + { + var header = + "GET / HTTP/1.1\r\n" + + "Host: example.com\r\n" + + "X-Url: https://other.example.com/a:b:c\r\n\r\n"; + + var fields = Http11Parser.Read(header.AsMemory()); + + var xurl = fields.Single(f => + f.Name.Span.Equals("X-Url", StringComparison.Ordinal)); + + Assert.Equal("https://other.example.com/a:b:c", xurl.Value.ToString()); + } + + [Fact] + public void Read_HandlesLfOnlyLineEndings() + { + var header = "GET / HTTP/1.1\nHost: example.com\nAccept: */*\n\n"; + + var fields = Http11Parser.Read(header.AsMemory()); + + Assert.Contains(fields, f => + f.Name.Span.Equals(":authority", StringComparison.Ordinal) + && f.Value.Span.Equals("example.com", StringComparison.Ordinal)); + + Assert.Contains(fields, f => + f.Name.Span.Equals("Accept", StringComparison.Ordinal)); + } + + [Fact] + public void Read_InvalidHeaderLine_Throws() + { + var header = "GET / HTTP/1.1\r\nHost: example.com\r\nBogusLineWithoutColon\r\n\r\n"; + + Assert.Throws(() => Http11Parser.Read(header.AsMemory())); + } + + [Fact] + public void Read_HostPortIsPreservedInAuthority() + { + var header = "GET / HTTP/1.1\r\nHost: example.com:8443\r\n\r\n"; + + var fields = Http11Parser.Read(header.AsMemory()); + + var authority = fields.Single(f => + f.Name.Span.Equals(":authority", StringComparison.Ordinal)); + + Assert.Equal("example.com:8443", authority.Value.ToString()); + } + + [Fact] + public void Read_EncodeRoundTrip_MatchesLegacyOrder() + { + // Exercises the exact order HPackEncoder.Encode now produces through the + // streaming reader. Uses the Req001 fixture round-tripped through + // Http11Parser.Read+Write (the hot path) as a sanity check. + var header = new UTF8Encoding(false).GetString(Headers.Req001); + + var fields = Http11Parser.Read(header.AsMemory()); + + // Pseudo-headers come first and in HPACK order. + Assert.Equal(":method", fields[0].Name.ToString()); + Assert.Equal(":scheme", fields[1].Name.ToString()); + Assert.Equal(":path", fields[2].Name.ToString()); + + // The Cookie header in the fixture is a single line with three entries — + // the default splitCookies=true should emit three separate cookie fields. + var cookieCount = fields.Count(f => + f.Name.Span.Equals("cookie", StringComparison.Ordinal)); + + Assert.Equal(3, cookieCount); + } + + private static void AssertHeader(HeaderField field, string name, string value) + { + Assert.Equal(name, field.Name.ToString()); + Assert.Equal(value, field.Value.ToString()); + } } } From 34c663870260c29edad19928b5838c029ef487ad Mon Sep 17 00:00:00 2001 From: Haga Rak Date: Sat, 11 Apr 2026 01:15:43 +0200 Subject: [PATCH 08/26] Update test plan --- benchmark-throughput.cmd | 4 +- fluxzy.core.slnx | 2 + test/Fluxzy.Benchmarks.Server/Program.cs | 2 +- .../Properties/launchSettings.json | 12 + .../ProxyThroughputBenchmark.cs | 267 +++++++++++++++++- .../_Fixtures/Socks5ClientFactory.cs | 9 +- 6 files changed, 289 insertions(+), 7 deletions(-) create mode 100644 test/Fluxzy.Benchmarks.Server/Properties/launchSettings.json diff --git a/benchmark-throughput.cmd b/benchmark-throughput.cmd index 4384e23c..cfe1e358 100644 --- a/benchmark-throughput.cmd +++ b/benchmark-throughput.cmd @@ -20,14 +20,14 @@ if /i "%~1"=="--contention" ( ) if /i "%~1"=="--h2-8k" ( rem H2 + 8192 body only, ~30%% of default duration - set SHORT_ARGS=--warmupCount 2 --iterationCount 5 --launchCount 1 + set SHORT_ARGS=--warmupCount 2 --iterationCount 10 --launchCount 1 set FILTER=*ProxyThroughputBenchmark*True*8192* shift goto parse_args ) if /i "%~1"=="--h2-0k" ( rem H2 + 0 body only, ~30%% of default duration - set SHORT_ARGS=--warmupCount 2 --iterationCount 5 --launchCount 1 + set SHORT_ARGS=--warmupCount 2 --iterationCount 10 --launchCount 1 set FILTER=*ProxyThroughputBenchmark*True*0* shift goto parse_args diff --git a/fluxzy.core.slnx b/fluxzy.core.slnx index dce8e3c0..203caf09 100644 --- a/fluxzy.core.slnx +++ b/fluxzy.core.slnx @@ -56,6 +56,8 @@ + + diff --git a/test/Fluxzy.Benchmarks.Server/Program.cs b/test/Fluxzy.Benchmarks.Server/Program.cs index 331d487c..fd53a653 100644 --- a/test/Fluxzy.Benchmarks.Server/Program.cs +++ b/test/Fluxzy.Benchmarks.Server/Program.cs @@ -35,7 +35,7 @@ if (length > 0) { - ctx.Response.ContentLength = length; + ctx.Response.ContentLength = 8192; var buffer = new byte[Math.Min(length, 16384)]; var remaining = length; diff --git a/test/Fluxzy.Benchmarks.Server/Properties/launchSettings.json b/test/Fluxzy.Benchmarks.Server/Properties/launchSettings.json new file mode 100644 index 00000000..07998629 --- /dev/null +++ b/test/Fluxzy.Benchmarks.Server/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "Fluxzy.Benchmarks.Server": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:63911;http://localhost:63912" + } + } +} \ No newline at end of file diff --git a/test/Fluxzy.Benchmarks/ProxyThroughputBenchmark.cs b/test/Fluxzy.Benchmarks/ProxyThroughputBenchmark.cs index 20d4ccd8..daf449d0 100644 --- a/test/Fluxzy.Benchmarks/ProxyThroughputBenchmark.cs +++ b/test/Fluxzy.Benchmarks/ProxyThroughputBenchmark.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Concurrent; using System.Diagnostics.Tracing; +using System.Globalization; using System.IO; using System.Linq; using System.Net; @@ -11,6 +13,7 @@ using BenchmarkDotNet.Configs; using BenchmarkDotNet.Diagnosers; using BenchmarkDotNet.Reports; +using BenchmarkDotNet.Running; using Fluxzy.Rules; using Fluxzy.Rules.Actions; using Fluxzy.Tests._Fixtures; @@ -36,6 +39,7 @@ public class ProxyThroughputBenchmark private HttpClient _client = null!; private SemaphoreSlim _semaphore = null!; private string _targetUrl = null!; + private ByteCounter _byteCounter = null!; [Params(true, false)] public bool ServeH2 { get; set; } @@ -64,13 +68,20 @@ public async Task Setup() // 3. Create HTTP client routed through proxy via SOCKS5 // (equivalent to: floody -c 16 -x 127.0.0.1:) + // The counting stream wraps the raw socket — TLS/HTTP layers sit above it, + // so the counter tallies actual TCP bytes (encrypted, includes TLS framing). var httpVersion = ServeH2 ? new Version(2, 0) : new Version(1, 1); - _client = Socks5ClientFactory.Create(endPoint, timeoutSeconds: 30, httpVersion: httpVersion); + _byteCounter = new ByteCounter(); + _client = Socks5ClientFactory.Create( + endPoint, + timeoutSeconds: 30, + httpVersion: httpVersion, + streamWrapper: s => new CountingStream(s, _byteCounter)); _targetUrl = $"{_server.BaseUrl}/bench?length={ResponseBodyLength}"; _semaphore = new SemaphoreSlim(Concurrency); - // Warmup: establish connections + // Warmup: establish connections (pays SOCKS5 + TLS handshake costs once) var warmupTasks = new Task[Concurrency]; for (var i = 0; i < Concurrency; i++) { @@ -78,6 +89,44 @@ public async Task Setup() } await Task.WhenAll(warmupTasks); + + // Measure real per-request wire bytes on already-warm connections, + // then persist for the BandwidthColumn (which runs in the host process). + await MeasureWireBytesPerRequestAsync(); + } + + private async Task MeasureWireBytesPerRequestAsync() + { + const int probeCount = 32; + + var inBefore = Interlocked.Read(ref _byteCounter.BytesIn); + var outBefore = Interlocked.Read(ref _byteCounter.BytesOut); + + var probeTasks = new Task[probeCount]; + + for (var i = 0; i < probeCount; i++) { + probeTasks[i] = SendRequest(); + } + + await Task.WhenAll(probeTasks); + + var inAfter = Interlocked.Read(ref _byteCounter.BytesIn); + var outAfter = Interlocked.Read(ref _byteCounter.BytesOut); + + var totalBytes = (inAfter - inBefore) + (outAfter - outBefore); + var bytesPerRequest = (double) totalBytes / probeCount; + + var path = GetMeasurementFilePath(ServeH2, ResponseBodyLength); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + await File.WriteAllTextAsync(path, bytesPerRequest.ToString("R", CultureInfo.InvariantCulture)); + } + + private static string GetMeasurementFilePath(bool serveH2, int responseBodyLength) + { + return Path.Combine( + Path.GetTempPath(), + "fluxzy-bench", + $"bandwidth-{serveH2}-{responseBodyLength}.txt"); } [GlobalCleanup] @@ -143,6 +192,7 @@ public Config() { WithSummaryStyle(SummaryStyle.Default.WithRatioStyle(RatioStyle.Percentage)); AddColumn(StatisticColumn.OperationsPerSecond); + AddColumn(new BandwidthColumn()); // Opt-in contention trace: FLUXZY_BENCH_CONTENTION=1 produces a .nettrace per benchmark // run (in BenchmarkDotNet.Artifacts/), openable in PerfView / VS / speedscope. @@ -165,4 +215,217 @@ public Config() } } } + + /// + /// Reports real wire bandwidth using the per-request byte count measured during + /// . The measurement counts actual + /// TCP bytes (post-TLS encryption, post-SOCKS5 framing) for steady-state requests + /// on already-warm connections, then this column multiplies by ops/sec to get + /// bytes/sec for each benchmark case. + /// + private class BandwidthColumn : IColumn + { + private static readonly ConcurrentDictionary Cache = new(); + + public string Id => nameof(BandwidthColumn); + public string ColumnName => "Bandwidth"; + public string Legend => "Real wire throughput (measured bytes/request × ops/sec)"; + public bool AlwaysShow => true; + public ColumnCategory Category => ColumnCategory.Statistics; + public int PriorityInCategory => 0; + public bool IsNumeric => true; + public UnitType UnitType => UnitType.Size; + + public bool IsAvailable(Summary summary) => true; + public bool IsDefault(Summary summary, BenchmarkCase benchmarkCase) => false; + + public string GetValue(Summary summary, BenchmarkCase benchmarkCase) + => GetValue(summary, benchmarkCase, SummaryStyle.Default); + + public string GetValue(Summary summary, BenchmarkCase benchmarkCase, SummaryStyle style) + { + var report = summary[benchmarkCase]; + + if (report?.ResultStatistics == null) + return "-"; + + var serveH2 = benchmarkCase.Parameters.Items + .FirstOrDefault(p => p.Name == nameof(ServeH2))?.Value as bool?; + var bodyLength = benchmarkCase.Parameters.Items + .FirstOrDefault(p => p.Name == nameof(ResponseBodyLength))?.Value as int?; + + if (serveH2 is null || bodyLength is null) + return "-"; + + var bytesPerRequest = LoadMeasurement(serveH2.Value, bodyLength.Value); + + if (bytesPerRequest is null or <= 0) + return "-"; + + // Mean is per-op time in nanoseconds (BDN already divided by OperationsPerInvoke). + var meanNanoseconds = report.ResultStatistics.Mean; + + if (meanNanoseconds <= 0) + return "-"; + + var opsPerSecond = 1_000_000_000d / meanNanoseconds; + var bytesPerSecond = opsPerSecond * bytesPerRequest.Value; + + return FormatBytesPerSecond(bytesPerSecond); + } + + private static double? LoadMeasurement(bool serveH2, int bodyLength) + { + var key = $"{serveH2}_{bodyLength}"; + + return Cache.GetOrAdd(key, _ => { + var path = GetMeasurementFilePath(serveH2, bodyLength); + + if (!File.Exists(path)) + return null; + + try { + var text = File.ReadAllText(path).Trim(); + + if (double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var value)) + return value; + } + catch { + // ignore — column degrades to "-" + } + + return null; + }); + } + + private static string FormatBytesPerSecond(double bytesPerSecond) + { + string[] units = { "B/s", "KiB/s", "MiB/s", "GiB/s" }; + var unitIndex = 0; + var value = bytesPerSecond; + + while (value >= 1024d && unitIndex < units.Length - 1) { + value /= 1024d; + unitIndex++; + } + + return value.ToString("F2", CultureInfo.InvariantCulture) + " " + units[unitIndex]; + } + } + + /// + /// Holds running totals of bytes read from / written to the underlying TCP socket. + /// Updated by via interlocked adds so it's safe to + /// share across the connection pool. + /// + private sealed class ByteCounter + { + public long BytesIn; + public long BytesOut; + } + + /// + /// Pass-through Stream that tallies bytes read and written into a shared + /// . Wraps the raw NetworkStream below the TLS layer, + /// so the count reflects actual TCP wire bytes including TLS/SOCKS5 framing. + /// + private sealed class CountingStream : Stream + { + private readonly Stream _inner; + private readonly ByteCounter _counter; + + public CountingStream(Stream inner, ByteCounter counter) + { + _inner = inner; + _counter = counter; + } + + public override bool CanRead => _inner.CanRead; + public override bool CanWrite => _inner.CanWrite; + public override bool CanSeek => false; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() => _inner.Flush(); + + public override Task FlushAsync(CancellationToken cancellationToken) + => _inner.FlushAsync(cancellationToken); + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override int Read(byte[] buffer, int offset, int count) + { + var n = _inner.Read(buffer, offset, count); + + if (n > 0) + Interlocked.Add(ref _counter.BytesIn, n); + + return n; + } + + public override async ValueTask ReadAsync( + Memory buffer, CancellationToken cancellationToken = default) + { + var n = await _inner.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + + if (n > 0) + Interlocked.Add(ref _counter.BytesIn, n); + + return n; + } + + public override async Task ReadAsync( + byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + var n = await _inner.ReadAsync(buffer.AsMemory(offset, count), cancellationToken) + .ConfigureAwait(false); + + if (n > 0) + Interlocked.Add(ref _counter.BytesIn, n); + + return n; + } + + public override void Write(byte[] buffer, int offset, int count) + { + _inner.Write(buffer, offset, count); + Interlocked.Add(ref _counter.BytesOut, count); + } + + public override async ValueTask WriteAsync( + ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + await _inner.WriteAsync(buffer, cancellationToken).ConfigureAwait(false); + Interlocked.Add(ref _counter.BytesOut, buffer.Length); + } + + public override async Task WriteAsync( + byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + await _inner.WriteAsync(buffer.AsMemory(offset, count), cancellationToken) + .ConfigureAwait(false); + Interlocked.Add(ref _counter.BytesOut, count); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + _inner.Dispose(); + + base.Dispose(disposing); + } + + public override async ValueTask DisposeAsync() + { + await _inner.DisposeAsync().ConfigureAwait(false); + await base.DisposeAsync().ConfigureAwait(false); + } + } } diff --git a/test/Fluxzy.Tests/_Fixtures/Socks5ClientFactory.cs b/test/Fluxzy.Tests/_Fixtures/Socks5ClientFactory.cs index 76bec9cd..54dd88e7 100644 --- a/test/Fluxzy.Tests/_Fixtures/Socks5ClientFactory.cs +++ b/test/Fluxzy.Tests/_Fixtures/Socks5ClientFactory.cs @@ -18,7 +18,8 @@ public static class Socks5ClientFactory { public static HttpClient Create( IPEndPoint proxyEndPoint, int timeoutSeconds = 15, - Version? httpVersion = null) + Version? httpVersion = null, + Func? streamWrapper = null) { var normalized = NormalizeEndPoint(proxyEndPoint); var useH2 = httpVersion != null && httpVersion.Major >= 2; @@ -43,7 +44,11 @@ public static HttpClient Create( var socket = new Socket(normalized.AddressFamily, SocketType.Stream, ProtocolType.Tcp); await socket.ConnectAsync(normalized, cancellationToken); - var stream = new NetworkStream(socket, ownsSocket: true); + Stream stream = new NetworkStream(socket, ownsSocket: true); + + if (streamWrapper != null) + stream = streamWrapper(stream); + await PerformSocks5HandshakeAsync(stream, context.DnsEndPoint, cancellationToken); return stream; From acc2cc1ae2a6c4169170f4b0bac06f4e6dd2815c Mon Sep 17 00:00:00 2001 From: Haga Rak Date: Sat, 11 Apr 2026 02:02:43 +0200 Subject: [PATCH 09/26] Remove threading diag --- test/Fluxzy.Benchmarks/ProxyThroughputBenchmark.cs | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/test/Fluxzy.Benchmarks/ProxyThroughputBenchmark.cs b/test/Fluxzy.Benchmarks/ProxyThroughputBenchmark.cs index daf449d0..ee344bf1 100644 --- a/test/Fluxzy.Benchmarks/ProxyThroughputBenchmark.cs +++ b/test/Fluxzy.Benchmarks/ProxyThroughputBenchmark.cs @@ -1,20 +1,12 @@ -using System; using System.Collections.Concurrent; using System.Diagnostics.Tracing; using System.Globalization; -using System.IO; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Columns; using BenchmarkDotNet.Configs; using BenchmarkDotNet.Diagnosers; using BenchmarkDotNet.Reports; using BenchmarkDotNet.Running; -using Fluxzy.Rules; using Fluxzy.Rules.Actions; using Fluxzy.Tests._Fixtures; using Microsoft.Diagnostics.NETCore.Client; @@ -28,11 +20,12 @@ namespace Fluxzy.Benchmarks; /// Run: dotnet run -c Release -- --filter *ProxyThroughputBenchmark* /// [MemoryDiagnoser] +//[ThreadingDiagnoser] [Config(typeof(Config))] public class ProxyThroughputBenchmark { private const int RequestsPerIteration = 500; - private const int Concurrency = 56; + private const int Concurrency = 32; private BenchmarkServerProcess _server = null!; private Proxy _proxy = null!; @@ -170,7 +163,7 @@ public async Task Throughput() private async Task SendRequest() { - using var response = await _client.GetAsync(_targetUrl, HttpCompletionOption.ResponseHeadersRead) + using var response = await _client.GetAsync(_targetUrl, HttpCompletionOption.ResponseContentRead) .ConfigureAwait(false); response.EnsureSuccessStatusCode(); From a6f4444171bf5603401f192ccd6d39ef6c55aa66 Mon Sep 17 00:00:00 2001 From: Haga Rak Date: Sat, 11 Apr 2026 02:02:58 +0200 Subject: [PATCH 10/26] Add contention analyzer --- tools/TraceContentionAnalyzer/Program.cs | 172 ++++++++++++++++++ .../TraceContentionAnalyzer.csproj | 12 ++ 2 files changed, 184 insertions(+) create mode 100644 tools/TraceContentionAnalyzer/Program.cs create mode 100644 tools/TraceContentionAnalyzer/TraceContentionAnalyzer.csproj diff --git a/tools/TraceContentionAnalyzer/Program.cs b/tools/TraceContentionAnalyzer/Program.cs new file mode 100644 index 00000000..8648a3dc --- /dev/null +++ b/tools/TraceContentionAnalyzer/Program.cs @@ -0,0 +1,172 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Microsoft.Diagnostics.Tracing.Etlx; +using Microsoft.Diagnostics.Tracing.Parsers.Clr; +using Microsoft.Diagnostics.Tracing.Stacks; + +namespace TraceContentionAnalyzer; + +/// +/// Loads a .nettrace captured with Microsoft-Windows-DotNETRuntime contention+stack +/// keywords and aggregates CLR ContentionStart events by their managed call stack so +/// you can see which locks are hot. +/// +internal static class Program +{ + private const int DefaultTopStacks = 15; + private const int DefaultStackDepth = 20; + + private static int Main(string[] args) + { + if (args.Length < 1) { + Console.Error.WriteLine("Usage: TraceContentionAnalyzer [topStacks] [stackDepth]"); + return 2; + } + + var tracePath = args[0]; + var topStacks = args.Length >= 2 ? int.Parse(args[1]) : DefaultTopStacks; + var stackDepth = args.Length >= 3 ? int.Parse(args[2]) : DefaultStackDepth; + + if (!File.Exists(tracePath)) { + Console.Error.WriteLine($"File not found: {tracePath}"); + return 2; + } + + Console.Error.WriteLine($"Indexing {Path.GetFileName(tracePath)} (may take a moment)..."); + + var etlxPath = TraceLog.CreateFromEventPipeDataFile(tracePath); + + try { + using var traceLog = new TraceLog(etlxPath); + AnalyzeContention(traceLog, topStacks, stackDepth); + } + finally { + try { File.Delete(etlxPath); } catch { /* ignore */ } + } + + return 0; + } + + private static void AnalyzeContention(TraceLog traceLog, int topStacks, int stackDepth) + { + var stacks = new Dictionary(StringComparer.Ordinal); + var source = traceLog.Events.GetSource(); + + var totalEvents = 0; + var withStack = 0; + + source.Clr.ContentionStart += evt => { + totalEvents++; + var callStack = evt.CallStack(); + + if (callStack == null) + return; + + withStack++; + + var frames = new List(stackDepth); + var cursor = callStack; + var depth = 0; + + while (cursor != null && depth < stackDepth) { + var method = cursor.CodeAddress.FullMethodName; + + if (!string.IsNullOrEmpty(method)) + frames.Add(method); + + cursor = cursor.Caller; + depth++; + } + + if (frames.Count == 0) + return; + + var key = string.Join("\n", frames); + + if (!stacks.TryGetValue(key, out var agg)) { + agg = new StackAggregate { Frames = frames }; + stacks[key] = agg; + } + + agg.Count++; + }; + + source.Process(); + + Console.WriteLine($"Total ContentionStart events : {totalEvents:N0}"); + Console.WriteLine($"With resolvable stack : {withStack:N0}"); + Console.WriteLine($"Unique stacks : {stacks.Count:N0}"); + Console.WriteLine(); + + if (stacks.Count == 0) { + Console.WriteLine("No stacks captured. Verify the trace includes the Stack (0x40000000) keyword."); + return; + } + + var top = stacks.Values + .OrderByDescending(s => s.Count) + .Take(topStacks) + .ToList(); + + var totalWithStack = Math.Max(withStack, 1); + + for (var i = 0; i < top.Count; i++) { + var agg = top[i]; + var pct = 100d * agg.Count / totalWithStack; + + Console.WriteLine($"=== #{i + 1} count={agg.Count:N0} ({pct:F1}% of stacked)"); + + foreach (var frame in agg.Frames) + Console.WriteLine($" {frame}"); + + Console.WriteLine(); + } + + // Lock-site rollup: skip past System.Threading.Monitor.* frames to find the first + // user-visible caller — that is the `lock(...)` site. More actionable than the raw + // leaf count (which is always Monitor.Enter_Slowpath). + var siteCounts = new Dictionary(StringComparer.Ordinal); + + foreach (var agg in stacks.Values) { + var site = FindLockSite(agg.Frames); + + if (site == null) + continue; + + if (!siteCounts.TryGetValue(site, out var n)) + n = 0; + + siteCounts[site] = n + agg.Count; + } + + Console.WriteLine("=== Top lock sites (first caller outside System.Threading.Monitor) ==="); + + foreach (var (site, count) in siteCounts.OrderByDescending(kv => kv.Value).Take(topStacks)) { + var pct = 100d * count / totalWithStack; + Console.WriteLine($" {count,8:N0} {pct,5:F1}% {site}"); + } + } + + private static string? FindLockSite(List frames) + { + foreach (var frame in frames) { + if (frame.StartsWith("System.Threading.Monitor.", StringComparison.Ordinal)) + continue; + + if (frame.StartsWith("System.Threading.ManualResetEventSlim.", StringComparison.Ordinal)) + continue; + + return frame; + } + + return null; + } + + private sealed class StackAggregate + { + public int Count; + public List Frames = new(); + } +} diff --git a/tools/TraceContentionAnalyzer/TraceContentionAnalyzer.csproj b/tools/TraceContentionAnalyzer/TraceContentionAnalyzer.csproj new file mode 100644 index 00000000..66da09a2 --- /dev/null +++ b/tools/TraceContentionAnalyzer/TraceContentionAnalyzer.csproj @@ -0,0 +1,12 @@ + + + Exe + latest + TraceContentionAnalyzer + TraceContentionAnalyzer + false + + + + + From a34c6c69a3fd157b61c4e48cc013e65f00425ebd Mon Sep 17 00:00:00 2001 From: haga-rak Date: Sat, 11 Apr 2026 03:26:29 +0200 Subject: [PATCH 11/26] Defer flush outside hot loop --- src/Fluxzy.Core/Core/H2DownStreamPipe.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Fluxzy.Core/Core/H2DownStreamPipe.cs b/src/Fluxzy.Core/Core/H2DownStreamPipe.cs index 418e2e65..a3ddff2d 100644 --- a/src/Fluxzy.Core/Core/H2DownStreamPipe.cs +++ b/src/Fluxzy.Core/Core/H2DownStreamPipe.cs @@ -561,8 +561,6 @@ await _writeStream.WriteAsync( ArrayPool.Shared.Return(gatherBuffer); // Phase 3: Flush - if (didWork) - await _writeStream.FlushAsync(token).ConfigureAwait(false); // Phase 4: Wait for signal Interlocked.Exchange(ref _writeSignalState, 0); @@ -590,6 +588,9 @@ await _writeStream.WriteAsync( break; } + if (didWork) + await _writeStream.FlushAsync(token).ConfigureAwait(false); + await _writeSignal.WaitAsync(token).ConfigureAwait(false); } } From b180954217b00af0d7319d030fd9d78d2dbaa7b0 Mon Sep 17 00:00:00 2001 From: haga-rak Date: Sat, 11 Apr 2026 03:26:58 +0200 Subject: [PATCH 12/26] Fix missing fluxzy on post payload --- src/Fluxzy.Core/Core/ServerStreamWorker.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Fluxzy.Core/Core/ServerStreamWorker.cs b/src/Fluxzy.Core/Core/ServerStreamWorker.cs index 059384d5..005487cb 100644 --- a/src/Fluxzy.Core/Core/ServerStreamWorker.cs +++ b/src/Fluxzy.Core/Core/ServerStreamWorker.cs @@ -154,6 +154,7 @@ public async Task ReceiveBodyFragment(H2FrameReadResult frame } await _requestBodyPipe.Writer.WriteAsync(buffer.Memory.Slice(0, length), token).ConfigureAwait(false); + await _requestBodyPipe.Writer.FlushAsync(token).ConfigureAwait(false); if (endStream) { From 920c18e853df5f785ec220ca2e368ca1cd09dbb6 Mon Sep 17 00:00:00 2001 From: haga-rak Date: Sun, 12 Apr 2026 01:49:02 +0200 Subject: [PATCH 13/26] Add context aware frame reader --- .../Clients/H2/H2ConnectionPool.cs | 8 +- .../Clients/H2/H2FrameStreamReader.cs | 147 ++++++++++++++++++ src/Fluxzy.Core/Clients/H2/H2Reader.cs | 25 --- src/Fluxzy.Core/Core/DataFrameEntry.cs | 34 ++++ src/Fluxzy.Core/Core/H2DownStreamPipe.cs | 47 +----- src/Fluxzy.Core/Core/PendingHeaderWrite.cs | 20 +++ .../ProxyThroughputBenchmark.cs | 2 +- 7 files changed, 207 insertions(+), 76 deletions(-) create mode 100644 src/Fluxzy.Core/Clients/H2/H2FrameStreamReader.cs create mode 100644 src/Fluxzy.Core/Core/DataFrameEntry.cs create mode 100644 src/Fluxzy.Core/Core/PendingHeaderWrite.cs diff --git a/src/Fluxzy.Core/Clients/H2/H2ConnectionPool.cs b/src/Fluxzy.Core/Clients/H2/H2ConnectionPool.cs index 363c7982..78ac5163 100644 --- a/src/Fluxzy.Core/Clients/H2/H2ConnectionPool.cs +++ b/src/Fluxzy.Core/Clients/H2/H2ConnectionPool.cs @@ -470,17 +470,15 @@ await _baseStream /// private async Task InternalReadLoop(CancellationToken token) { - using var readBuffer = MemoryPool.Shared.Rent(Setting.Remote.MaxFrameSize); + using var reader = new H2FrameStreamReader(_baseStream, Setting.Remote.MaxFrameSize); Exception? outException = null; try { while (!token.IsCancellationRequested) { _logger.TraceDeep(0, () => "1"); - - var frame = - await H2FrameReader.ReadNextFrameAsync(_baseStream, readBuffer.Memory, - token).ConfigureAwait(false); + + var frame = await reader.ReadNextFrameAsync(token).ConfigureAwait(false); if (ProcessNewFrame(frame)) break; diff --git a/src/Fluxzy.Core/Clients/H2/H2FrameStreamReader.cs b/src/Fluxzy.Core/Clients/H2/H2FrameStreamReader.cs new file mode 100644 index 00000000..637cb463 --- /dev/null +++ b/src/Fluxzy.Core/Clients/H2/H2FrameStreamReader.cs @@ -0,0 +1,147 @@ +// Copyright 2021 - Haga Rakotoharivelo - https://github.com/haga-rak + +using System; +using System.Buffers; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Fluxzy.Clients.H2 +{ + /// + /// Reads consecutive H2 frames from a single underlying using a private + /// buffer it owns. Amortizes calls + /// across frames: a single network read can satisfy multiple small frames, and the common path + /// completes synchronously as a without allocating a state machine. + /// + /// + /// + /// Lifetime contract. The returned from + /// exposes a that points into + /// the reader's owned buffer. It is valid only until the next call to + /// on the same reader. Callers must copy the body bytes out + /// (or finish reading from the slice) before requesting the next frame. + /// + /// + /// Not thread-safe. Intended to be driven by a single read loop per connection. + /// + /// + internal sealed class H2FrameStreamReader : IDisposable + { + private readonly Stream _stream; + private readonly int _maxFrameSize; + private readonly IMemoryOwner _owner; + private readonly Memory _buffer; + + // Unread window: bytes in [_start, _end) have been read from the stream + // but not yet returned to the caller as a parsed frame. + private int _start; + private int _end; + + public H2FrameStreamReader(Stream stream, int maxFrameSize) + { + _stream = stream; + _maxFrameSize = maxFrameSize; + + // Two full max-size frames (header + body) of headroom, so a typical read after + // a compaction always has room to land a full frame in one shot. + var bufferSize = (maxFrameSize + 9) * 2; + _owner = MemoryPool.Shared.Rent(bufferSize); + _buffer = _owner.Memory.Slice(0, bufferSize); + } + + public ValueTask ReadNextFrameAsync(CancellationToken cancellationToken) + { + // Fast path: a complete frame is already buffered from a previous read. + var available = _end - _start; + + if (available >= 9) { + var header = new H2Frame(_buffer.Span.Slice(_start, 9)); + + if (header.BodyLength > _maxFrameSize) + ThrowFrameTooLarge(header.BodyLength); + + var total = 9 + header.BodyLength; + + if (available >= total) { + var body = _buffer.Slice(_start + 9, header.BodyLength); + _start += total; + return new ValueTask(new H2FrameReadResult(header, body)); + } + } + + return ReadNextFrameSlowAsync(cancellationToken); + } + + private async ValueTask ReadNextFrameSlowAsync(CancellationToken cancellationToken) + { + // Ensure at least a full header is buffered. + while (_end - _start < 9) { + if (!await FillAsync(cancellationToken).ConfigureAwait(false)) { + // Clean EOF is only valid on a frame boundary. + if (_end - _start == 0) + return default; + + throw new EndOfStreamException("Unexpected EOF while reading H2 frame header"); + } + } + + var header = new H2Frame(_buffer.Span.Slice(_start, 9)); + + if (header.BodyLength > _maxFrameSize) + ThrowFrameTooLarge(header.BodyLength); + + var total = 9 + header.BodyLength; + + while (_end - _start < total) { + if (!await FillAsync(cancellationToken).ConfigureAwait(false)) + throw new EndOfStreamException("Unexpected EOF while reading H2 frame body"); + } + + var body = _buffer.Slice(_start + 9, header.BodyLength); + _start += total; + + return new H2FrameReadResult(header, body); + } + + private async ValueTask FillAsync(CancellationToken cancellationToken) + { + // Compact when the tail cannot fit a max-size frame. With buffer = 2*(maxFrameSize+9) + // and available < maxFrameSize+9 (any complete frame would already have been returned), + // compaction always yields tailFree > maxFrameSize+9 afterwards. + var tailFree = _buffer.Length - _end; + + if (tailFree < _maxFrameSize + 9) + CompactUnreadBytes(); + + var read = await _stream.ReadAsync(_buffer.Slice(_end), cancellationToken).ConfigureAwait(false); + + if (read <= 0) + return false; + + _end += read; + return true; + } + + private void CompactUnreadBytes() + { + var available = _end - _start; + + if (available > 0 && _start > 0) + _buffer.Slice(_start, available).CopyTo(_buffer); + + _start = 0; + _end = available; + } + + private static void ThrowFrameTooLarge(int bodyLength) + { + throw new IOException($"Received frame is too large than MaxFrameSizeAllowed ({bodyLength})"); + } + + public void Dispose() + { + _owner.Dispose(); + } + } +} diff --git a/src/Fluxzy.Core/Clients/H2/H2Reader.cs b/src/Fluxzy.Core/Clients/H2/H2Reader.cs index ab974cbb..3549cde0 100644 --- a/src/Fluxzy.Core/Clients/H2/H2Reader.cs +++ b/src/Fluxzy.Core/Clients/H2/H2Reader.cs @@ -1,36 +1,11 @@ // Copyright 2021 - Haga Rakotoharivelo - https://github.com/haga-rak using System; -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using Fluxzy.Misc.Streams; namespace Fluxzy.Clients.H2 { public class H2FrameReader { - public static async ValueTask ReadNextFrameAsync( - Stream stream, Memory buffer, CancellationToken cancellationToken) - { - var headerBuffer = buffer.Slice(0, 9); - - if (!await stream.ReadExactAsync(headerBuffer, cancellationToken).ConfigureAwait(false)) - return default; - - var frame = new H2Frame(headerBuffer.Span); - - if (buffer.Length < frame.BodyLength) - throw new IOException($"Received frame is too large than MaxFrameSizeAllowed ({frame.BodyLength})"); - - var bodyBuffer = buffer.Slice(0, frame.BodyLength); - - if (!await stream.ReadExactAsync(bodyBuffer, cancellationToken).ConfigureAwait(false)) - throw new EndOfStreamException("Unexpected EOF"); - - return new H2FrameReadResult(frame, bodyBuffer); - } - public static H2FrameReadResult ReadFrame(ref ReadOnlyMemory inputBuffer) { var headerBuffer = inputBuffer.Slice(0, 9); diff --git a/src/Fluxzy.Core/Core/DataFrameEntry.cs b/src/Fluxzy.Core/Core/DataFrameEntry.cs new file mode 100644 index 00000000..692329ea --- /dev/null +++ b/src/Fluxzy.Core/Core/DataFrameEntry.cs @@ -0,0 +1,34 @@ +// Copyright 2021 - Haga Rakotoharivelo - https://github.com/haga-rak + +using System.Collections.Generic; +using Fluxzy.Clients.H2.Encoder; + +namespace Fluxzy.Core +{ + internal readonly struct DataFrameEntry + { + public readonly byte[]? RentedBuffer; + public readonly int Length; + public readonly int FlowControlledBytes; + public readonly IList? TrailerHeaders; + public readonly int TrailerStreamIdentifier; + + public DataFrameEntry(byte[] rentedBuffer, int length, int flowControlledBytes) + { + RentedBuffer = rentedBuffer; + Length = length; + FlowControlledBytes = flowControlledBytes; + TrailerHeaders = null; + TrailerStreamIdentifier = 0; + } + + public DataFrameEntry(IList trailerHeaders, int trailerStreamIdentifier) + { + RentedBuffer = null; + Length = 0; + FlowControlledBytes = 0; + TrailerHeaders = trailerHeaders; + TrailerStreamIdentifier = trailerStreamIdentifier; + } + } +} diff --git a/src/Fluxzy.Core/Core/H2DownStreamPipe.cs b/src/Fluxzy.Core/Core/H2DownStreamPipe.cs index a3ddff2d..41d16df1 100644 --- a/src/Fluxzy.Core/Core/H2DownStreamPipe.cs +++ b/src/Fluxzy.Core/Core/H2DownStreamPipe.cs @@ -3,7 +3,6 @@ using System; using System.Buffers; using System.Collections.Concurrent; -using System.Collections.Generic; using System.IO; using System.Threading; using System.Threading.Channels; @@ -18,47 +17,6 @@ namespace Fluxzy.Core { - internal readonly struct DataFrameEntry - { - public readonly byte[]? RentedBuffer; - public readonly int Length; - public readonly int FlowControlledBytes; - public readonly IList? TrailerHeaders; - public readonly int TrailerStreamIdentifier; - - public DataFrameEntry(byte[] rentedBuffer, int length, int flowControlledBytes) - { - RentedBuffer = rentedBuffer; - Length = length; - FlowControlledBytes = flowControlledBytes; - TrailerHeaders = null; - TrailerStreamIdentifier = 0; - } - - public DataFrameEntry(IList trailerHeaders, int trailerStreamIdentifier) - { - RentedBuffer = null; - Length = 0; - FlowControlledBytes = 0; - TrailerHeaders = trailerHeaders; - TrailerStreamIdentifier = trailerStreamIdentifier; - } - } - - internal readonly struct PendingHeaderWrite - { - public readonly ReadOnlyMemory Http11Header; - public readonly int StreamIdentifier; - public readonly bool HasBody; - - public PendingHeaderWrite(ReadOnlyMemory http11Header, int streamIdentifier, bool hasBody) - { - Http11Header = http11Header; - StreamIdentifier = streamIdentifier; - HasBody = hasBody; - } - } - internal class H2DownStreamPipe : IDownStreamPipe { private readonly Stream _readStream; @@ -286,6 +244,7 @@ private void CheckoutServerStreamWorker(ServerStreamWorker streamWorker) private async Task ReadLoop(CancellationToken token) { try { + using var reader = new H2FrameStreamReader(_readStream, _h2StreamSetting.MaxFrameSizeAllowed); using var readBuffer = RsBuffer.Allocate(_h2StreamSetting.MaxFrameSizeAllowed + 9); while (!token.IsCancellationRequested) { @@ -293,9 +252,7 @@ private async Task ReadLoop(CancellationToken token) H2FrameReadResult frame; try { - frame = - await H2FrameReader.ReadNextFrameAsync(_readStream, readBuffer.Memory, - token).ConfigureAwait(false); + frame = await reader.ReadNextFrameAsync(token).ConfigureAwait(false); } catch (OperationCanceledException) { break; diff --git a/src/Fluxzy.Core/Core/PendingHeaderWrite.cs b/src/Fluxzy.Core/Core/PendingHeaderWrite.cs new file mode 100644 index 00000000..668afed2 --- /dev/null +++ b/src/Fluxzy.Core/Core/PendingHeaderWrite.cs @@ -0,0 +1,20 @@ +// Copyright 2021 - Haga Rakotoharivelo - https://github.com/haga-rak + +using System; + +namespace Fluxzy.Core +{ + internal readonly struct PendingHeaderWrite + { + public readonly ReadOnlyMemory Http11Header; + public readonly int StreamIdentifier; + public readonly bool HasBody; + + public PendingHeaderWrite(ReadOnlyMemory http11Header, int streamIdentifier, bool hasBody) + { + Http11Header = http11Header; + StreamIdentifier = streamIdentifier; + HasBody = hasBody; + } + } +} diff --git a/test/Fluxzy.Benchmarks/ProxyThroughputBenchmark.cs b/test/Fluxzy.Benchmarks/ProxyThroughputBenchmark.cs index ee344bf1..f51de81e 100644 --- a/test/Fluxzy.Benchmarks/ProxyThroughputBenchmark.cs +++ b/test/Fluxzy.Benchmarks/ProxyThroughputBenchmark.cs @@ -25,7 +25,7 @@ namespace Fluxzy.Benchmarks; public class ProxyThroughputBenchmark { private const int RequestsPerIteration = 500; - private const int Concurrency = 32; + private const int Concurrency = 56; private BenchmarkServerProcess _server = null!; private Proxy _proxy = null!; From 1c0d8913dcd3b9f480c87cfe721afd1065abdfe6 Mon Sep 17 00:00:00 2001 From: haga-rak Date: Sun, 12 Apr 2026 02:39:36 +0200 Subject: [PATCH 14/26] Update iteration for short benchmark --- benchmark-throughput.cmd | 2 +- benchmark-throughput.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/benchmark-throughput.cmd b/benchmark-throughput.cmd index cfe1e358..0be5347f 100644 --- a/benchmark-throughput.cmd +++ b/benchmark-throughput.cmd @@ -7,7 +7,7 @@ set SHORT_ARGS= :parse_args if "%~1"=="" goto done_args if /i "%~1"=="--short" ( - set SHORT_ARGS=--warmupCount 1 --iterationCount 3 --launchCount 1 + set SHORT_ARGS=--warmupCount 1 --iterationCount 20 --launchCount 1 shift goto parse_args ) diff --git a/benchmark-throughput.sh b/benchmark-throughput.sh index c6985dbe..0e20aa92 100755 --- a/benchmark-throughput.sh +++ b/benchmark-throughput.sh @@ -8,7 +8,7 @@ FILTER="" while [[ $# -gt 0 ]]; do case "$1" in --short) - SHORT_ARGS="--warmupCount 1 --iterationCount 3 --launchCount 1" + SHORT_ARGS="--warmupCount 1 --iterationCount 10 --launchCount 1" shift ;; --contention) From a31a40cbd00770e03b25d9d415f20939b60ae56b Mon Sep 17 00:00:00 2001 From: haga-rak Date: Sun, 12 Apr 2026 16:03:51 +0200 Subject: [PATCH 15/26] Add late flush for write pool --- src/Fluxzy.Core/Clients/H2/H2ConnectionPool.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Fluxzy.Core/Clients/H2/H2ConnectionPool.cs b/src/Fluxzy.Core/Clients/H2/H2ConnectionPool.cs index 78ac5163..2e5b1574 100644 --- a/src/Fluxzy.Core/Clients/H2/H2ConnectionPool.cs +++ b/src/Fluxzy.Core/Clients/H2/H2ConnectionPool.cs @@ -440,16 +440,19 @@ await _baseStream } } - await _baseStream.FlushAsync(token).ConfigureAwait(false); _lastActivity = ITimingProvider.Default.Instant(); } else { + + await _baseStream.FlushAsync(token).ConfigureAwait(false); // async wait if (!token.IsCancellationRequested && !await _writerChannel.Reader.WaitToReadAsync(token)) break; } } + + await _baseStream.FlushAsync(token).ConfigureAwait(false); } catch (OperationCanceledException) { } From 5333f7a47f59cea0abf8924ad7e37a8518c81ef7 Mon Sep 17 00:00:00 2001 From: haga-rak Date: Sun, 12 Apr 2026 16:34:26 +0200 Subject: [PATCH 16/26] Optimize HPACK decode path: eliminate redundant Huffman traversals and LINQ allocations - Add ReadStringPrefix to read wire length without Huffman decoding, replace GetStringLength calls in HPackDecoder to eliminate double/triple tree walks - Remove redundant GetDecodedLength from ReadString, use upper-bound sizing - Replace Encoding.ASCII with direct byte<->char widening loops - Replace LINQ (Sum, Where, ToDictionary, Select) in Http11Parser.InternalWrite with foreach loops and inline cookie joining --- .../H2/Encoder/HPack/PrimitiveOperation.cs | 52 ++++-- .../Clients/H2/Encoder/HPackDecoder.cs | 100 +++++------ .../Clients/H2/Encoder/Utils/Http11Parser.cs | 160 +++++++++++++----- 3 files changed, 197 insertions(+), 115 deletions(-) diff --git a/src/Fluxzy.Core/Clients/H2/Encoder/HPack/PrimitiveOperation.cs b/src/Fluxzy.Core/Clients/H2/Encoder/HPack/PrimitiveOperation.cs index d8f4cc7f..b6a8f54f 100644 --- a/src/Fluxzy.Core/Clients/H2/Encoder/HPack/PrimitiveOperation.cs +++ b/src/Fluxzy.Core/Clients/H2/Encoder/HPack/PrimitiveOperation.cs @@ -2,7 +2,6 @@ using System; using System.Buffers; -using System.Text; using Fluxzy.Clients.H2.Encoder.Huffman; namespace Fluxzy.Clients.H2.Encoder.HPack @@ -120,6 +119,23 @@ public int GetStringLength(ReadOnlySpan input) } } + /// + /// Reads the string wire prefix (huffman flag and wire byte length) without any Huffman decoding. + /// Returns the number of prefix bytes consumed from input. + /// + public int ReadStringPrefix(ReadOnlySpan input, out int wireLength, out bool isHuffman) + { + isHuffman = (input[0] & 0x80) != 0; + var prefixBytes = ReadInt32(input, 7, out wireLength); + + if (wireLength > _maxStringLength) { + throw new HPackCodecException( + $"string length exceed the maximum authorized : {wireLength} / {_maxStringLength}"); + } + + return prefixBytes; + } + public Span ReadString(ReadOnlySpan input, Span buffer, out int newOffset) { try { @@ -132,32 +148,33 @@ public Span ReadString(ReadOnlySpan input, Span buffer, out in } var rawString = input.Slice(offset, stringLength); + newOffset = stringLength + offset; if (!huffmanEncoded) { - var size = Encoding.ASCII.GetChars(rawString, buffer); - var res = buffer.Slice(0, size); - - newOffset = stringLength + offset; + // Direct byte-to-char widening (HPACK strings are ASCII) + for (var i = 0; i < rawString.Length; i++) + buffer[i] = (char) rawString[i]; - return res; + return buffer.Slice(0, rawString.Length); } - newOffset = stringLength + offset; - - var decodedLength = _codec.GetDecodedLength(rawString); + // Upper bound for Huffman: shortest code is 5 bits, so max decoded = wireLen * 8/5 < wireLen * 2 + var maxDecodedLength = stringLength * 2; byte[]? heapBuffer = null; - var decodeBuffer = decodedLength < 1024 - ? stackalloc byte[decodedLength] - : heapBuffer = ArrayPool.Shared.Rent(decodedLength); + var decodeBuffer = maxDecodedLength < 1024 + ? stackalloc byte[maxDecodedLength] + : heapBuffer = ArrayPool.Shared.Rent(maxDecodedLength); try { var decoded = _codec.Decode(rawString, decodeBuffer); - var resultLength = Encoding.ASCII.GetChars(decoded, buffer); + // Direct byte-to-char widening (HPACK strings are ASCII) + for (var i = 0; i < decoded.Length; i++) + buffer[i] = (char) decoded[i]; - return buffer.Slice(0, resultLength); + return buffer.Slice(0, decoded.Length); } finally { if (heapBuffer != null) @@ -178,8 +195,11 @@ public Span WriteString(ReadOnlySpan input, Span buffer, bool ? stackalloc byte[input.Length * 2] : heapBuffer = ArrayPool.Shared.Rent(input.Length * 2); - var size = Encoding.ASCII.GetBytes(input, inputByteBuffer); - var inputBytes = inputByteBuffer.Slice(0, size); + // Direct char-to-byte narrowing (HPACK strings are ASCII) + for (var i = 0; i < input.Length; i++) + inputByteBuffer[i] = (byte) input[i]; + + var inputBytes = inputByteBuffer.Slice(0, input.Length); var encodedLength = _codec.GetEncodedLength(inputBytes); diff --git a/src/Fluxzy.Core/Clients/H2/Encoder/HPackDecoder.cs b/src/Fluxzy.Core/Clients/H2/Encoder/HPackDecoder.cs index 0390480d..983a1b39 100644 --- a/src/Fluxzy.Core/Clients/H2/Encoder/HPackDecoder.cs +++ b/src/Fluxzy.Core/Clients/H2/Encoder/HPackDecoder.cs @@ -49,33 +49,6 @@ internal HPackDecoder( public void Dispose() { } - - public ReadOnlySpan Decode( - ReadOnlySpan headerContent, Span buffer, - ref IList originalFields) - { - _tempEntries.Clear(); - - try { - for (;;) { - var tableEntry = ReadNextField(headerContent, out var readen); - - if (readen <= 0) - break; - - _tempEntries.Add(tableEntry); - originalFields.Add(tableEntry); - - headerContent = headerContent.Slice(readen); - } - - return Http11Parser.Write(_tempEntries, buffer); - } - finally { - _tempEntries.Clear(); - } - } - public ReadOnlySpan Decode(ReadOnlySpan headerContent, Span buffer) { _tempEntries.Clear(); @@ -166,17 +139,18 @@ private HeaderField ReadNextField(in ReadOnlySpan buffer, out int bytesRea $"Requested headerIndex does not exist in static table {headerIndex}"); } - var stringLength = _primitiveOperation.GetStringLength(buffer.Slice(offsetLength)); + var valPrefix = buffer.Slice(offsetLength); + _primitiveOperation.ReadStringPrefix(valPrefix, out var valWireLen, out var valIsHuffman); + var valCharBudget = valIsHuffman ? valWireLen * 2 : valWireLen; var lineBuffer = - stringLength < _codecSetting.MaxStackAllocationLength - ? stackalloc char[stringLength] - : new char[stringLength]; + valCharBudget <= _codecSetting.MaxStackAllocationLength + ? stackalloc char[valCharBudget] + : new char[valCharBudget]; var headerValue = _primitiveOperation - .ReadString(buffer.Slice(offsetLength) - , lineBuffer, out var headerValueLength); + .ReadString(valPrefix, lineBuffer, out var headerValueLength); bytesReaden = offsetLength + headerValueLength; @@ -184,23 +158,27 @@ private HeaderField ReadNextField(in ReadOnlySpan buffer, out int bytesRea } case HeaderFieldType.LiteralHeaderFieldIncrementalIndexingWithName: { - var headerNameLength = _primitiveOperation.GetStringLength(buffer.Slice(1)); + var nameSlice = buffer.Slice(1); + _primitiveOperation.ReadStringPrefix(nameSlice, out var nameWireLen, out var nameIsHuffman); + var nameCharBudget = nameIsHuffman ? nameWireLen * 2 : nameWireLen; var headerNameBuffer = - headerNameLength < _codecSetting.MaxStackAllocationLength - ? stackalloc char[headerNameLength] - : new char[headerNameLength]; + nameCharBudget <= _codecSetting.MaxStackAllocationLength + ? stackalloc char[nameCharBudget] + : new char[nameCharBudget]; var headerName = - _primitiveOperation.ReadString(buffer.Slice(1), headerNameBuffer, out var offsetHeaderName); + _primitiveOperation.ReadString(nameSlice, headerNameBuffer, out var offsetHeaderName); - var headerValueLength = _primitiveOperation.GetStringLength(buffer.Slice(1 + offsetHeaderName)); + var valSlice = buffer.Slice(1 + offsetHeaderName); + _primitiveOperation.ReadStringPrefix(valSlice, out var valWireLen2, out var valIsHuffman2); + var valCharBudget2 = valIsHuffman2 ? valWireLen2 * 2 : valWireLen2; - var headerValueBuffer = headerValueLength < _codecSetting.MaxStackAllocationLength - ? stackalloc char[headerValueLength] - : new char[headerValueLength]; + var headerValueBuffer = valCharBudget2 <= _codecSetting.MaxStackAllocationLength + ? stackalloc char[valCharBudget2] + : new char[valCharBudget2]; - var headerValue = _primitiveOperation.ReadString(buffer.Slice(1 + offsetHeaderName), + var headerValue = _primitiveOperation.ReadString(valSlice, headerValueBuffer, out var offsetHeaderValue); bytesReaden = 1 + offsetHeaderName + offsetHeaderValue; @@ -215,14 +193,16 @@ private HeaderField ReadNextField(in ReadOnlySpan buffer, out int bytesRea if (!Context.TryGetEntry(index, out var tableEntry)) throw new HPackCodecException($"Referenced index header {index} is absent from decodingTable"); - var resultStringLength = _primitiveOperation.GetStringLength(buffer.Slice(offsetLength)); + var valSlice3 = buffer.Slice(offsetLength); + _primitiveOperation.ReadStringPrefix(valSlice3, out var valWireLen3, out var valIsHuffman3); + var valCharBudget3 = valIsHuffman3 ? valWireLen3 * 2 : valWireLen3; var lineBuffer = - resultStringLength < _codecSetting.MaxStackAllocationLength - ? stackalloc char[resultStringLength] - : new char[resultStringLength]; + valCharBudget3 <= _codecSetting.MaxStackAllocationLength + ? stackalloc char[valCharBudget3] + : new char[valCharBudget3]; - var resultString = _primitiveOperation.ReadString(buffer.Slice(offsetLength), lineBuffer, + var resultString = _primitiveOperation.ReadString(valSlice3, lineBuffer, out var offsetValueLength); bytesReaden = offsetLength + offsetValueLength; @@ -232,24 +212,28 @@ private HeaderField ReadNextField(in ReadOnlySpan buffer, out int bytesRea case HeaderFieldType.LiteralHeaderFieldNeverIndexWithName: case HeaderFieldType.LiteralHeaderFieldWithoutIndexingWithName: { - var headerNameLength = _primitiveOperation.GetStringLength(buffer.Slice(1)); + var nameSlice4 = buffer.Slice(1); + _primitiveOperation.ReadStringPrefix(nameSlice4, out var nameWireLen4, out var nameIsHuffman4); + var nameCharBudget4 = nameIsHuffman4 ? nameWireLen4 * 2 : nameWireLen4; var headerNameBuffer = - headerNameLength < _codecSetting.MaxStackAllocationLength - ? stackalloc char[headerNameLength] - : new char[headerNameLength]; + nameCharBudget4 <= _codecSetting.MaxStackAllocationLength + ? stackalloc char[nameCharBudget4] + : new char[nameCharBudget4]; var headerName = - _primitiveOperation.ReadString(buffer.Slice(1), headerNameBuffer, out var nameLength); + _primitiveOperation.ReadString(nameSlice4, headerNameBuffer, out var nameLength); - var headerValueLength = _primitiveOperation.GetStringLength(buffer.Slice(1 + nameLength)); + var valSlice4 = buffer.Slice(1 + nameLength); + _primitiveOperation.ReadStringPrefix(valSlice4, out var valWireLen4, out var valIsHuffman4); + var valCharBudget4 = valIsHuffman4 ? valWireLen4 * 2 : valWireLen4; var headerValueBuffer = - headerValueLength < _codecSetting.MaxStackAllocationLength - ? stackalloc char[headerValueLength] - : new char[headerValueLength]; + valCharBudget4 <= _codecSetting.MaxStackAllocationLength + ? stackalloc char[valCharBudget4] + : new char[valCharBudget4]; - var headerValue = _primitiveOperation.ReadString(buffer.Slice(1 + nameLength), headerValueBuffer, + var headerValue = _primitiveOperation.ReadString(valSlice4, headerValueBuffer, out var valueLength); bytesReaden = 1 + nameLength + valueLength; diff --git a/src/Fluxzy.Core/Clients/H2/Encoder/Utils/Http11Parser.cs b/src/Fluxzy.Core/Clients/H2/Encoder/Utils/Http11Parser.cs index bf6b213b..035d456d 100644 --- a/src/Fluxzy.Core/Clients/H2/Encoder/Utils/Http11Parser.cs +++ b/src/Fluxzy.Core/Clients/H2/Encoder/Utils/Http11Parser.cs @@ -3,7 +3,6 @@ using System; using System.Buffers; using System.Collections.Generic; -using System.Linq; using Fluxzy.Clients.H2.Encoder.HPack; namespace Fluxzy.Clients.H2.Encoder.Utils @@ -34,7 +33,10 @@ public static Span Write( char[]? heapBuffer = null; try { - var minimumLength = entries.Sum(s => s.Size) + 64; + var minimumLength = 64; + + foreach (var entry in entries) + minimumLength += entry.Size; var cookieBuffer = minimumLength < 1024 ? stackalloc char[minimumLength] @@ -63,7 +65,10 @@ public static Span Write( char[]? heapBuffer = null; try { - var minimumLength = entries.Sum(s => s.Value.Length + s.Name.Length + 32) + 64; + var minimumLength = 64; + + foreach (var entry in entries) + minimumLength += entry.Value.Length + entry.Name.Length + 32; var cookieBuffer = minimumLength < 1024 ? stackalloc char[minimumLength] @@ -83,36 +88,62 @@ private static int InternalWrite( in ICollection entries, in Span buffer, in Span cookieBuffer) { - var mapping = entries - .Where(t => Http11Constants.ControlHeaders.Contains(t.Name)) - .ToDictionary - (t => t.Name, t => t, SpanCharactersIgnoreCaseComparer.Default); + // Linear scan for control headers (max 5) — avoids Dictionary + LINQ allocations + var method = default(HeaderField); + var status = default(HeaderField); + var path = default(HeaderField); + var authority = default(HeaderField); + var hasMethod = false; + var hasStatus = false; + var hasPath = false; + var hasAuthority = false; + + foreach (var entry in entries) { + var name = entry.Name; + + if (name.Span.Equals(Http11Constants.MethodVerb.Span, StringComparison.OrdinalIgnoreCase)) { + method = entry; + hasMethod = true; + } + else if (name.Span.Equals(Http11Constants.StatusVerb.Span, StringComparison.OrdinalIgnoreCase)) { + status = entry; + hasStatus = true; + } + else if (name.Span.Equals(Http11Constants.PathVerb.Span, StringComparison.OrdinalIgnoreCase)) { + path = entry; + hasPath = true; + } + else if (name.Span.Equals(Http11Constants.AuthorityVerb.Span, StringComparison.OrdinalIgnoreCase)) { + authority = entry; + hasAuthority = true; + } + } var totalWritten = 0; var offsetBuffer = buffer; - if (!mapping.TryGetValue(Http11Constants.MethodVerb, out var method)) { - if (!mapping.TryGetValue(Http11Constants.StatusVerb, out var statusHeader)) + if (!hasMethod) { + if (!hasStatus) throw new HPackCodecException("Invalid HTTP header. Could not find :method or :status"); - // Response header + // Response header SpanCharsHelper.Concat(ref offsetBuffer, ref totalWritten, "HTTP/1.1 "); - SpanCharsHelper.Concat(ref offsetBuffer, ref totalWritten, statusHeader.Value.Span); + SpanCharsHelper.Concat(ref offsetBuffer, ref totalWritten, status.Value.Span); SpanCharsHelper.Concat(ref offsetBuffer, ref totalWritten, " "); SpanCharsHelper.Concat(ref offsetBuffer, ref totalWritten, - Http11Constants.GetStatusLine(statusHeader.Value).Span); + Http11Constants.GetStatusLine(status.Value).Span); SpanCharsHelper.Concat(ref offsetBuffer, ref totalWritten, "\r\n"); } else { // Request Header - if (!mapping.TryGetValue(Http11Constants.PathVerb, out var path)) + if (!hasPath) throw new HPackCodecException("Could not find path verb"); - if (!mapping.TryGetValue(Http11Constants.AuthorityVerb, out var authority)) + if (!hasAuthority) throw new HPackCodecException("Could not find authority verb"); SpanCharsHelper.Concat(ref offsetBuffer, ref totalWritten, method.Value.Span); @@ -125,7 +156,24 @@ private static int InternalWrite( SpanCharsHelper.Concat(ref offsetBuffer, ref totalWritten, "\r\n"); } + // Write non-pseudo headers and join cookies in a single pass + var cookieOffset = 0; + var cookieOffsetBuffer = cookieBuffer; + var hasCookie = false; + foreach (var entry in entries) { + if (entry.Name.Span.Equals(Http11Constants.CookieVerb.Span, StringComparison.OrdinalIgnoreCase)) { + // Accumulate cookie values with "; " separator + if (hasCookie) { + SpanCharsHelper.Concat(ref cookieOffsetBuffer, ref cookieOffset, "; "); + } + + SpanCharsHelper.Concat(ref cookieOffsetBuffer, ref cookieOffset, entry.Value.Span); + hasCookie = true; + + continue; + } + if (Http11Constants.AvoidAutoParseHttp11Headers.Contains(entry.Name)) continue; // PSEUDO headers @@ -135,14 +183,8 @@ private static int InternalWrite( SpanCharsHelper.Concat(ref offsetBuffer, ref totalWritten, "\r\n"); } - var cookieLength = SpanCharsHelper.Join( - entries.Where(c => - c.Name.Span.Equals(Http11Constants.CookieVerb.Span, StringComparison.OrdinalIgnoreCase) - ).Select(s => s.Value), "; ".AsSpan(), cookieBuffer); - - var cookieValue = cookieBuffer.Slice(0, cookieLength); - - if (!cookieValue.IsEmpty) { + if (hasCookie) { + var cookieValue = cookieBuffer.Slice(0, cookieOffset); SpanCharsHelper.Concat(ref offsetBuffer, ref totalWritten, Http11Constants.CookieVerb.Span); SpanCharsHelper.Concat(ref offsetBuffer, ref totalWritten, ": "); SpanCharsHelper.Concat(ref offsetBuffer, ref totalWritten, cookieValue); @@ -158,36 +200,62 @@ private static int InternalWrite( in ICollection entries, in Span buffer, in Span cookieBuffer) { - var mapping = entries - .Where(t => Http11Constants.ControlHeaders.Contains(t.Name)) - .ToDictionary - (t => t.Name, t => t, SpanCharactersIgnoreCaseComparer.Default); + // Linear scan for control headers — avoids Dictionary + LINQ allocations + var method = default(HeaderFieldInfo); + var status = default(HeaderFieldInfo); + var path = default(HeaderFieldInfo); + var authority = default(HeaderFieldInfo); + var hasMethod = false; + var hasStatus = false; + var hasPath = false; + var hasAuthority = false; + + foreach (var entry in entries) { + var name = entry.Name; + + if (name.Span.Equals(Http11Constants.MethodVerb.Span, StringComparison.OrdinalIgnoreCase)) { + method = entry; + hasMethod = true; + } + else if (name.Span.Equals(Http11Constants.StatusVerb.Span, StringComparison.OrdinalIgnoreCase)) { + status = entry; + hasStatus = true; + } + else if (name.Span.Equals(Http11Constants.PathVerb.Span, StringComparison.OrdinalIgnoreCase)) { + path = entry; + hasPath = true; + } + else if (name.Span.Equals(Http11Constants.AuthorityVerb.Span, StringComparison.OrdinalIgnoreCase)) { + authority = entry; + hasAuthority = true; + } + } var totalWritten = 0; var offsetBuffer = buffer; - if (!mapping.TryGetValue(Http11Constants.MethodVerb, out var method)) { - if (!mapping.TryGetValue(Http11Constants.StatusVerb, out var statusHeader)) + if (!hasMethod) { + if (!hasStatus) throw new HPackCodecException("Invalid HTTP header. Could not find :method or :status"); - // Response header + // Response header SpanCharsHelper.Concat(ref offsetBuffer, ref totalWritten, "HTTP/1.1 "); - SpanCharsHelper.Concat(ref offsetBuffer, ref totalWritten, statusHeader.Value.Span); + SpanCharsHelper.Concat(ref offsetBuffer, ref totalWritten, status.Value.Span); SpanCharsHelper.Concat(ref offsetBuffer, ref totalWritten, " "); SpanCharsHelper.Concat(ref offsetBuffer, ref totalWritten, - Http11Constants.GetStatusLine(statusHeader.Value).Span); + Http11Constants.GetStatusLine(status.Value).Span); SpanCharsHelper.Concat(ref offsetBuffer, ref totalWritten, "\r\n"); } else { // Request Header - if (!mapping.TryGetValue(Http11Constants.PathVerb, out var path)) + if (!hasPath) throw new HPackCodecException("Could not find path verb"); - if (!mapping.TryGetValue(Http11Constants.AuthorityVerb, out var authority)) + if (!hasAuthority) throw new HPackCodecException("Could not find authority verb"); SpanCharsHelper.Concat(ref offsetBuffer, ref totalWritten, method.Value.Span); @@ -200,7 +268,23 @@ private static int InternalWrite( SpanCharsHelper.Concat(ref offsetBuffer, ref totalWritten, "\r\n"); } + // Write non-pseudo headers and join cookies in a single pass + var cookieOffset = 0; + var cookieOffsetBuffer = cookieBuffer; + var hasCookie = false; + foreach (var entry in entries) { + if (entry.Name.Span.Equals(Http11Constants.CookieVerb.Span, StringComparison.OrdinalIgnoreCase)) { + if (hasCookie) { + SpanCharsHelper.Concat(ref cookieOffsetBuffer, ref cookieOffset, "; "); + } + + SpanCharsHelper.Concat(ref cookieOffsetBuffer, ref cookieOffset, entry.Value.Span); + hasCookie = true; + + continue; + } + if (Http11Constants.AvoidAutoParseHttp11Headers.Contains(entry.Name)) continue; // PSEUDO headers @@ -210,14 +294,8 @@ private static int InternalWrite( SpanCharsHelper.Concat(ref offsetBuffer, ref totalWritten, "\r\n"); } - var cookieLength = SpanCharsHelper.Join( - entries.Where(c => - c.Name.Span.Equals(Http11Constants.CookieVerb.Span, StringComparison.OrdinalIgnoreCase) - ).Select(s => s.Value), "; ".AsSpan(), cookieBuffer); - - var cookieValue = cookieBuffer.Slice(0, cookieLength); - - if (!cookieValue.IsEmpty) { + if (hasCookie) { + var cookieValue = cookieBuffer.Slice(0, cookieOffset); SpanCharsHelper.Concat(ref offsetBuffer, ref totalWritten, Http11Constants.CookieVerb.Span); SpanCharsHelper.Concat(ref offsetBuffer, ref totalWritten, ": "); SpanCharsHelper.Concat(ref offsetBuffer, ref totalWritten, cookieValue); From 4a2026affeb601e46f26da97be9e85eaa2d4cfc4 Mon Sep 17 00:00:00 2001 From: haga-rak Date: Sun, 12 Apr 2026 17:24:47 +0200 Subject: [PATCH 17/26] Update chanel settings for exchange queue --- src/Fluxzy.Core/Core/H2DownStreamPipe.cs | 63 +++++++++++------------- 1 file changed, 30 insertions(+), 33 deletions(-) diff --git a/src/Fluxzy.Core/Core/H2DownStreamPipe.cs b/src/Fluxzy.Core/Core/H2DownStreamPipe.cs index 41d16df1..c94bcee0 100644 --- a/src/Fluxzy.Core/Core/H2DownStreamPipe.cs +++ b/src/Fluxzy.Core/Core/H2DownStreamPipe.cs @@ -27,7 +27,9 @@ internal class H2DownStreamPipe : IDownStreamPipe private const int RingBufferCapacity = 512 * 1024; private readonly Channel _exchangeChannel = - Channel.CreateUnbounded(); + Channel.CreateUnbounded(new () { + SingleWriter = true, SingleReader = true + }); private readonly CircularWriteBuffer _ringBuffer; @@ -389,8 +391,25 @@ private async Task ReadLoop(CancellationToken token) } } + private async ValueTask FlushRingBufferAsync(CancellationToken token) + { + _ringBuffer.GetReadableRegions(out var seg1, out var seg2, out var total); + + if (total > 0) { + if (seg1.Length > 0) + await _writeStream.WriteAsync(seg1, token).ConfigureAwait(false); + + if (seg2.Length > 0) + await _writeStream.WriteAsync(seg2, token).ConfigureAwait(false); + + _ringBuffer.Advance(total); + } + } + private async Task WriteLoop(CancellationToken token) { + var gatherBuffer = ArrayPool.Shared.Rent(GatherBufferSize); + try { while (!token.IsCancellationRequested) { var didWork = false; @@ -404,22 +423,13 @@ private async Task WriteLoop(CancellationToken token) didWork = true; } - _ringBuffer.GetReadableRegions(out var seg1, out var seg2, out var total); - - if (total > 0) { - if (seg1.Length > 0) - await _writeStream.WriteAsync(seg1, token).ConfigureAwait(false); - - if (seg2.Length > 0) - await _writeStream.WriteAsync(seg2, token).ConfigureAwait(false); - - _ringBuffer.Advance(total); + if (_ringBuffer.ReadableCount > 0) { + await FlushRingBufferAsync(token).ConfigureAwait(false); didWork = true; } // Phase 2: Drain data channel respecting connection window. // Gather consecutive DATA frames into a single write to reduce syscalls. - byte[]? gatherBuffer = null; var gatherOffset = 0; while (_dataChannel.Reader.TryPeek(out var entry)) { @@ -434,30 +444,20 @@ private async Task WriteLoop(CancellationToken token) // Interleave: flush gathered data and drain ring buffer if priority data exists if (_ringBuffer.ReadableCount > 0) { if (gatherOffset > 0) { - await _writeStream.WriteAsync(gatherBuffer!.AsMemory(0, gatherOffset), token).ConfigureAwait(false); + await _writeStream.WriteAsync(gatherBuffer.AsMemory(0, gatherOffset), token).ConfigureAwait(false); gatherOffset = 0; didWork = true; } - _ringBuffer.GetReadableRegions(out var pri1, out var pri2, out var priTotal); - - if (priTotal > 0) { - if (pri1.Length > 0) - await _writeStream.WriteAsync(pri1, token).ConfigureAwait(false); - - if (pri2.Length > 0) - await _writeStream.WriteAsync(pri2, token).ConfigureAwait(false); - - _ringBuffer.Advance(priTotal); - didWork = true; - } + await FlushRingBufferAsync(token).ConfigureAwait(false); + didWork = true; } // Trailer-encoding job (placed inline in the DATA channel to preserve // per-stream ordering relative to the DATA frames queued ahead of it). if (entry.TrailerHeaders != null) { if (gatherOffset > 0) { - await _writeStream.WriteAsync(gatherBuffer!.AsMemory(0, gatherOffset), token).ConfigureAwait(false); + await _writeStream.WriteAsync(gatherBuffer.AsMemory(0, gatherOffset), token).ConfigureAwait(false); gatherOffset = 0; } @@ -491,8 +491,6 @@ await _writeStream.WriteAsync( } // Gather mode: accumulate frames for batched write - gatherBuffer ??= ArrayPool.Shared.Rent(GatherBufferSize); - if (gatherOffset + entry.Length > gatherBuffer.Length) { // Flush current batch before it overflows if (gatherOffset > 0) { @@ -510,13 +508,10 @@ await _writeStream.WriteAsync( // Flush remaining gathered data if (gatherOffset > 0) { - await _writeStream.WriteAsync(gatherBuffer!.AsMemory(0, gatherOffset), token).ConfigureAwait(false); + await _writeStream.WriteAsync(gatherBuffer.AsMemory(0, gatherOffset), token).ConfigureAwait(false); didWork = true; } - if (gatherBuffer != null) - ArrayPool.Shared.Return(gatherBuffer); - // Phase 3: Flush // Phase 4: Wait for signal @@ -547,7 +542,7 @@ await _writeStream.WriteAsync( if (didWork) await _writeStream.FlushAsync(token).ConfigureAwait(false); - + await _writeSignal.WaitAsync(token).ConfigureAwait(false); } } @@ -558,6 +553,8 @@ await _writeStream.WriteAsync( throw; } finally { + ArrayPool.Shared.Return(gatherBuffer); + // Return rented buffers from any remaining channel entries while (_dataChannel.Reader.TryRead(out var remaining)) { if (remaining.RentedBuffer != null) From 786d9a91dab12348e471dc278285f30c2f6a1d61 Mon Sep 17 00:00:00 2001 From: haga-rak Date: Sun, 12 Apr 2026 17:43:04 +0200 Subject: [PATCH 18/26] Better hashcode --- src/Fluxzy.Core/Core/Authority.cs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/Fluxzy.Core/Core/Authority.cs b/src/Fluxzy.Core/Core/Authority.cs index 0e89c847..485aea10 100644 --- a/src/Fluxzy.Core/Core/Authority.cs +++ b/src/Fluxzy.Core/Core/Authority.cs @@ -45,11 +45,10 @@ public override bool Equals(object? obj) public override int GetHashCode() { if (HostName == null) - return 0; + return 0; - Span destBuffer = stackalloc char[HostName.Length]; - - return HashCode.Combine(HostName.AsSpan().ToLowerInvariant(destBuffer), Port, Secure); + return HashCode.Combine( + StringComparer.OrdinalIgnoreCase.GetHashCode(HostName), Port, Secure); } public override string ToString() From 8ce78a466ba3c835b16ebbe14271e609f14652bf Mon Sep 17 00:00:00 2001 From: haga-rak Date: Sun, 12 Apr 2026 18:15:58 +0200 Subject: [PATCH 19/26] Optimize GetPool hot path: lock-free pool reuse, init-before-store, ConcurrentDictionary - Hoist pool-reuse lookup before the per-authority semaphore so the common case (pool already exists) skips Synchronizer overhead entirely - Move Init() before storing pools in the dictionary, closing a race window where an uninitialised pool was briefly visible to other threads - Replace Dictionary+lock with ConcurrentDictionary for lock-free reads - Skip redundant lock(_locks) in Synchronizer when preserve=true --- src/Fluxzy.Core/Clients/PoolBuilder.cs | 242 ++++++++++++------------- src/Fluxzy.Core/Utils/Synchronizer.cs | 18 +- 2 files changed, 134 insertions(+), 126 deletions(-) diff --git a/src/Fluxzy.Core/Clients/PoolBuilder.cs b/src/Fluxzy.Core/Clients/PoolBuilder.cs index 2f1e3ae7..42592fde 100644 --- a/src/Fluxzy.Core/Clients/PoolBuilder.cs +++ b/src/Fluxzy.Core/Clients/PoolBuilder.cs @@ -39,8 +39,7 @@ static PoolBuilder() private readonly RealtimeArchiveWriter _archiveWriter; private readonly IDnsSolver _dnsSolver; - private readonly IDictionary _connectionPools = - new Dictionary(); + private readonly ConcurrentDictionary _connectionPools = new(); private readonly CancellationTokenSource _poolCheckHaltSource = new(); private readonly RemoteConnectionBuilder _remoteConnectionBuilder; @@ -97,11 +96,7 @@ private async void CheckPoolStatus(CancellationToken token) /// internal async Task CheckAllPoolsOnceAsync() { - List activePools; - - lock (_connectionPools) { - activePools = _connectionPools.Values.ToList(); - } + var activePools = _connectionPools.Values.ToList(); // Sequential: CheckAlive does no network I/O on either the alive or the // idle-teardown branch, so concurrency would only add coordination cost. @@ -150,152 +145,159 @@ await proxyRuntimeSetting.EnforceRules(exchange.Context, return mockedConnectionPool; } - IHttpConnectionPool? result = null; + var forceNewConnection = exchange.Context.ForceNewConnection; - try - { - using var _ = await _synchronizer.LockAsync(exchange.Authority); + if (exchange.Request.Header.IsWebSocketRequest || exchange.Context.BlindMode) + forceNewConnection = true; + + // Fast path: reuse existing pool without acquiring the per-authority semaphore. + // Pools are only stored after Init(), so any pool found here is fully initialised. + if (!forceNewConnection + && _connectionPools.TryGetValue(exchange.Authority, out var existingPool) + && !existingPool.Complete) { + + if (exchange.Metrics.RetrievingPool == default) + exchange.Metrics.RetrievingPool = ITimingProvider.Default.Instant(); - var forceNewConnection = exchange.Context.ForceNewConnection; + exchange.Metrics.ReusingConnection = true; - if (exchange.Request.Header.IsWebSocketRequest || exchange.Context.BlindMode) - forceNewConnection = true; + return existingPool; + } - // Looking for existing HttpPool + // Slow path: acquire per-authority semaphore for pool creation + using var syncGuard = await _synchronizer.LockAsync(exchange.Authority); - if (!forceNewConnection) { - lock (_connectionPools) - { - if (_connectionPools.TryGetValue(exchange.Authority, out var pool)) - { - if (pool.Complete) { - _connectionPools.Remove(pool.Authority); - } - else { - if (exchange.Metrics.RetrievingPool == default) - exchange.Metrics.RetrievingPool = ITimingProvider.Default.Instant(); + // Double-check after acquiring semaphore — another thread may have + // created the pool while we waited. + if (!forceNewConnection) { + if (_connectionPools.TryGetValue(exchange.Authority, out var pool)) { + if (pool.Complete) { + _connectionPools.TryRemove(exchange.Authority, out _); + } + else { + if (exchange.Metrics.RetrievingPool == default) + exchange.Metrics.RetrievingPool = ITimingProvider.Default.Instant(); - exchange.Metrics.ReusingConnection = true; + exchange.Metrics.ReusingConnection = true; - return pool; - } - } + return pool; } } + } - if (exchange.Metrics.RetrievingPool == default) - exchange.Metrics.RetrievingPool = ITimingProvider.Default.Instant(); + if (exchange.Metrics.RetrievingPool == default) + exchange.Metrics.RetrievingPool = ITimingProvider.Default.Instant(); + // pool + if (exchange.Context.BlindMode && exchange.Authority.Secure) { + var tunneledConnectionPool = new TunnelOnlyConnectionPool( + exchange.Authority, _timingProvider, + _remoteConnectionBuilder, proxyRuntimeSetting, dnsResolutionResult); - // pool - if (exchange.Context.BlindMode && exchange.Authority.Secure) { - var tunneledConnectionPool = new TunnelOnlyConnectionPool( - exchange.Authority, _timingProvider, - _remoteConnectionBuilder, proxyRuntimeSetting, dnsResolutionResult); + tunneledConnectionPool.Init(); - return result = tunneledConnectionPool; - } + return tunneledConnectionPool; + } - if (exchange.Request.Header.IsWebSocketRequest) { - var tunneledConnectionPool = new WebsocketConnectionPool( - exchange.Authority, _timingProvider, - _remoteConnectionBuilder, proxyRuntimeSetting, dnsResolutionResult); + if (exchange.Request.Header.IsWebSocketRequest) { + var tunneledConnectionPool = new WebsocketConnectionPool( + exchange.Authority, _timingProvider, + _remoteConnectionBuilder, proxyRuntimeSetting, dnsResolutionResult); - return result = tunneledConnectionPool; - } + tunneledConnectionPool.Init(); - if (!exchange.Authority.Secure) { - // Plain HTTP/1, no h2c + return tunneledConnectionPool; + } - var http11ConnectionPool = new Http11ConnectionPool(exchange.Authority, - _remoteConnectionBuilder, _timingProvider, proxyRuntimeSetting, - _archiveWriter!, dnsResolutionResult); + if (!exchange.Authority.Secure) { + // Plain HTTP/1, no h2c - exchange.HttpVersion = "HTTP/1.1"; + var http11ConnectionPool = new Http11ConnectionPool(exchange.Authority, + _remoteConnectionBuilder, _timingProvider, proxyRuntimeSetting, + _archiveWriter!, dnsResolutionResult); - if (exchange.Context.PreMadeResponse != null) - { - return new MockedConnectionPool(exchange.Authority, - exchange.Context.PreMadeResponse); - } + exchange.HttpVersion = "HTTP/1.1"; - lock (_connectionPools) { - return result = _connectionPools[exchange.Authority] = http11ConnectionPool; - } + if (exchange.Context.PreMadeResponse != null) + { + return new MockedConnectionPool(exchange.Authority, + exchange.Context.PreMadeResponse); } - // HTTPS test 1.1/2 + http11ConnectionPool.Init(); + _connectionPools[exchange.Authority] = http11ConnectionPool; - RemoteConnectionResult openingResult; - try - { - openingResult = - (await _remoteConnectionBuilder.OpenConnectionToRemote( - exchange, dnsResolutionResult, - exchange.Context.SslApplicationProtocols ?? AllProtocols, proxyRuntimeSetting, - exchange.Context.ProxyConfiguration, - cancellationToken).ConfigureAwait(false))!; - - if (exchange.Context.PreMadeResponse != null) - { - return new MockedConnectionPool(exchange.Authority, - exchange.Context.PreMadeResponse); - } + return http11ConnectionPool; + } - } - catch { - if (exchange.Connection != null) - _archiveWriter.Update(exchange.Connection, cancellationToken); + // HTTPS test 1.1/2 - throw; + RemoteConnectionResult openingResult; + try + { + openingResult = + (await _remoteConnectionBuilder.OpenConnectionToRemote( + exchange, dnsResolutionResult, + exchange.Context.SslApplicationProtocols ?? AllProtocols, proxyRuntimeSetting, + exchange.Context.ProxyConfiguration, + cancellationToken).ConfigureAwait(false))!; + + if (exchange.Context.PreMadeResponse != null) + { + return new MockedConnectionPool(exchange.Authority, + exchange.Context.PreMadeResponse); } + } + catch { + if (exchange.Connection != null) + _archiveWriter.Update(exchange.Connection, cancellationToken); - if (openingResult.Type == RemoteConnectionResultType.Http11) { - var http11ConnectionPool = new Http11ConnectionPool(exchange.Authority, - _remoteConnectionBuilder, _timingProvider, proxyRuntimeSetting, _archiveWriter, - dnsResolutionResult); + throw; + } - exchange.HttpVersion = exchange.Connection!.HttpVersion = "HTTP/1.1"; - _archiveWriter.Update(openingResult.Connection, cancellationToken); + if (openingResult.Type == RemoteConnectionResultType.Http11) { + var http11ConnectionPool = new Http11ConnectionPool(exchange.Authority, + _remoteConnectionBuilder, _timingProvider, proxyRuntimeSetting, _archiveWriter, + dnsResolutionResult); - lock (_connectionPools) { - return result = _connectionPools[exchange.Authority] = http11ConnectionPool; - } - } + exchange.HttpVersion = exchange.Connection!.HttpVersion = "HTTP/1.1"; - if (openingResult.Type == RemoteConnectionResultType.Http2) { - var h2ConnectionPool = new H2ConnectionPool( - openingResult.Connection - .ReadStream!, // Read and write stream are the same after the sslhandshake - exchange.Context.AdvancedTlsSettings.H2StreamSetting ?? new H2StreamSetting(), - exchange.Authority, exchange.Connection!, OnConnectionFaulted); + _archiveWriter.Update(openingResult.Connection, cancellationToken); - exchange.HttpVersion = exchange.Connection!.HttpVersion = "HTTP/2"; + http11ConnectionPool.Init(); + _connectionPools[exchange.Authority] = http11ConnectionPool; - if (_archiveWriter != null!) - _archiveWriter.Update(openingResult.Connection, cancellationToken); + return http11ConnectionPool; + } - lock (_connectionPools) { - return result = _connectionPools[exchange.Authority] = h2ConnectionPool; - } - } + if (openingResult.Type == RemoteConnectionResultType.Http2) { + var h2ConnectionPool = new H2ConnectionPool( + openingResult.Connection + .ReadStream!, // Read and write stream are the same after the sslhandshake + exchange.Context.AdvancedTlsSettings.H2StreamSetting ?? new H2StreamSetting(), + exchange.Authority, exchange.Connection!, OnConnectionFaulted); - throw new NotSupportedException($"Unhandled protocol type {openingResult.Type}"); - } - finally { - if (result != null) { - try - { - result.Init(); - } - catch - { - OnConnectionFaulted(result); - } + exchange.HttpVersion = exchange.Connection!.HttpVersion = "HTTP/2"; + + if (_archiveWriter != null!) + _archiveWriter.Update(openingResult.Connection, cancellationToken); + + try { + h2ConnectionPool.Init(); + } + catch { + _ = ObserveDisposal(h2ConnectionPool); + throw; } + + _connectionPools[exchange.Authority] = h2ConnectionPool; + + return h2ConnectionPool; } + + throw new NotSupportedException($"Unhandled protocol type {openingResult.Type}"); } private IDnsSolver ResolveDnsProvider(Exchange exchange, ProxyRuntimeSetting proxyRuntimeSetting) @@ -313,18 +315,12 @@ private IDnsSolver ResolveDnsProvider(Exchange exchange, ProxyRuntimeSetting pro /// internal void TryAddPoolForTests(Authority authority, IHttpConnectionPool pool) { - lock (_connectionPools) { - _connectionPools[authority] = pool; - } + _connectionPools[authority] = pool; } private void OnConnectionFaulted(IHttpConnectionPool h2ConnectionPool) { - bool removed; - - lock (_connectionPools) { - removed = _connectionPools.Remove(h2ConnectionPool.Authority); - } + var removed = _connectionPools.TryRemove(h2ConnectionPool.Authority, out _); if (!removed) return; diff --git a/src/Fluxzy.Core/Utils/Synchronizer.cs b/src/Fluxzy.Core/Utils/Synchronizer.cs index b77ee371..4fab0e7c 100644 --- a/src/Fluxzy.Core/Utils/Synchronizer.cs +++ b/src/Fluxzy.Core/Utils/Synchronizer.cs @@ -24,11 +24,21 @@ public async ValueTask LockAsync(T key) { LockInfo lockInfo; - lock (_locks) + if (_preserve) { + // With preserve=true entries are never removed, so + // ConcurrentDictionary.GetOrAdd is sufficient — no global lock needed. lockInfo = _locks.GetOrAdd(key, _ => new LockInfo()); Interlocked.Increment(ref lockInfo.WaitingCount); } + else + { + lock (_locks) + { + lockInfo = _locks.GetOrAdd(key, _ => new LockInfo()); + Interlocked.Increment(ref lockInfo.WaitingCount); + } + } await lockInfo.Semaphore.WaitAsync(); @@ -43,9 +53,11 @@ private void Release(T key, LockInfo lockInfo) Interlocked.Decrement(ref lockInfo.OwnerCount); lockInfo.Semaphore.Release(); + if (_preserve) + return; + lock (_locks) { - if (!_preserve - && Volatile.Read(ref lockInfo.WaitingCount) == 0 + if (Volatile.Read(ref lockInfo.WaitingCount) == 0 && Volatile.Read(ref lockInfo.OwnerCount) == 0) { var pair = new KeyValuePair(key, lockInfo); From 6b737fc8406d31a6b90ecea2316e1012aed12808 Mon Sep 17 00:00:00 2001 From: haga-rak Date: Sun, 12 Apr 2026 18:26:00 +0200 Subject: [PATCH 20/26] ServerStreamWorker return ValueTask instead of Task as it will be non async in most path --- src/Fluxzy.Core/Core/ServerStreamWorker.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Fluxzy.Core/Core/ServerStreamWorker.cs b/src/Fluxzy.Core/Core/ServerStreamWorker.cs index 005487cb..055c8500 100644 --- a/src/Fluxzy.Core/Core/ServerStreamWorker.cs +++ b/src/Fluxzy.Core/Core/ServerStreamWorker.cs @@ -177,7 +177,7 @@ public async Task ReceiveBodyFragment(H2FrameReadResult frame public bool ReadyToCreateExchange => _endHeader && !_exchangeCreated; - public async Task CreateExchange( + public async ValueTask CreateExchange( IIdProvider idProvider, IExchangeContextBuilder contextBuilder, Authority authority, bool secure) From 9318665a59e4f13406d7cfffc537fda1fd5c3896 Mon Sep 17 00:00:00 2001 From: haga-rak Date: Sun, 12 Apr 2026 18:40:45 +0200 Subject: [PATCH 21/26] Optimize EnforceRules hot path: pre-partition rules by FilterScope EnforceRules is called 4+ times per exchange and previously iterated every rule, performing scope-matching (including a type check for MultipleScopeAction) per rule per call. Rules are now partitioned into per-scope arrays once at Init/UpdateRules time, so EnforceRules just looks up the scope bucket and iterates only the rules that apply. PartitionedRules is immutable and swapped atomically via volatile + Interlocked.Exchange, preserving the existing hot-reload guarantees. --- src/Fluxzy.Core/Core/PartitionedRules.cs | 89 +++++++++ src/Fluxzy.Core/Core/ProxyRuntimeSetting.cs | 33 ++-- .../UnitTests/Core/PartitionedRulesTests.cs | 185 ++++++++++++++++++ 3 files changed, 287 insertions(+), 20 deletions(-) create mode 100644 src/Fluxzy.Core/Core/PartitionedRules.cs create mode 100644 test/Fluxzy.Tests/UnitTests/Core/PartitionedRulesTests.cs diff --git a/src/Fluxzy.Core/Core/PartitionedRules.cs b/src/Fluxzy.Core/Core/PartitionedRules.cs new file mode 100644 index 00000000..25708acc --- /dev/null +++ b/src/Fluxzy.Core/Core/PartitionedRules.cs @@ -0,0 +1,89 @@ +// Copyright 2021 - Haga Rakotoharivelo - https://github.com/haga-rak + +using System; +using System.Collections.Generic; +using System.Linq; +using Fluxzy.Rules; + +namespace Fluxzy.Core +{ + /// + /// Holds rules pre-partitioned by FilterScope for O(1) lookup in EnforceRules. + /// Immutable after construction; designed for atomic swap via volatile reference. + /// + internal sealed class PartitionedRules + { + private static readonly FilterScope[] PipelineScopes = + { + FilterScope.OnAuthorityReceived, + FilterScope.RequestHeaderReceivedFromClient, + FilterScope.DnsSolveDone, + FilterScope.RequestBodyReceivedFromClient, + FilterScope.ResponseHeaderReceivedFromRemote, + FilterScope.ResponseBodyReceivedFromRemote + }; + + private readonly Dictionary _byScope; + + public PartitionedRules(IReadOnlyList allRules) + { + AllRules = allRules; + _byScope = BuildPartitions(allRules); + } + + /// + /// The complete ordered rule list. Used by GetCurrentAlterationRules(). + /// + public IReadOnlyList AllRules { get; } + + /// + /// Returns the rules that should execute for the given scope. + /// + public Rule[] GetRulesForScope(FilterScope scope) + { + return _byScope.TryGetValue(scope, out var rules) ? rules : Array.Empty(); + } + + private static Dictionary BuildPartitions(IReadOnlyList allRules) + { + var buckets = new Dictionary>(); + + foreach (var scope in PipelineScopes) { + buckets[scope] = new List(); + } + + foreach (var rule in allRules) { + var actionScope = rule.Action.ActionScope; + + if (actionScope == FilterScope.OutOfScope) { + // OutOfScope rules run at every pipeline scope + foreach (var scope in PipelineScopes) { + buckets[scope].Add(rule); + } + } + else if (actionScope == FilterScope.CopySibling) { + // CopySibling + MultipleScopeAction with non-null RunScope + if (rule.Action is MultipleScopeAction msa && msa.RunScope != null) { + if (buckets.TryGetValue(msa.RunScope.Value, out var bucket)) { + bucket.Add(rule); + } + } + } + else { + // Direct scope match + if (buckets.TryGetValue(actionScope, out var bucket)) { + bucket.Add(rule); + } + } + } + + var result = new Dictionary(buckets.Count); + + foreach (var kvp in buckets) { + result[kvp.Key] = kvp.Value.ToArray(); + } + + return result; + } + } +} diff --git a/src/Fluxzy.Core/Core/ProxyRuntimeSetting.cs b/src/Fluxzy.Core/Core/ProxyRuntimeSetting.cs index bb546fdc..6b46d377 100644 --- a/src/Fluxzy.Core/Core/ProxyRuntimeSetting.cs +++ b/src/Fluxzy.Core/Core/ProxyRuntimeSetting.cs @@ -17,7 +17,7 @@ namespace Fluxzy.Core { internal class ProxyRuntimeSetting { - private volatile IReadOnlyList? _effectiveRules; + private volatile PartitionedRules? _partitionedRules; private readonly object _ruleUpdateLock = new object(); private ProxyRuntimeSetting() @@ -125,8 +125,8 @@ public void Init() rule.Filter.Init(startupContext); } - if (_effectiveRules == null) { - _effectiveRules = activeRules.AsReadOnly(); + if (_partitionedRules == null) { + _partitionedRules = new PartitionedRules(activeRules.AsReadOnly()); } } @@ -139,7 +139,7 @@ public void Init() /// If rule initialization fails public void UpdateRules(IEnumerable newAlterationRules) { - if (_effectiveRules == null) { + if (_partitionedRules == null) { throw new InvalidOperationException("Rules not initialized. Call Init() first."); } @@ -159,8 +159,8 @@ public void UpdateRules(IEnumerable newAlterationRules) } // Atomic swap - thread-safe visibility - var newReadOnlyList = activeRules.AsReadOnly(); - System.Threading.Interlocked.Exchange(ref _effectiveRules, newReadOnlyList); + var newPartitioned = new PartitionedRules(activeRules.AsReadOnly()); + System.Threading.Interlocked.Exchange(ref _partitionedRules, newPartitioned); } catch (Exception ex) { throw new RuleInitializationException("Failed to update rules: " + ex.Message, ex); @@ -173,9 +173,9 @@ public void UpdateRules(IEnumerable newAlterationRules) /// public IReadOnlyCollection GetCurrentAlterationRules() { - var currentRules = _effectiveRules; + var current = _partitionedRules; - if (currentRules == null) { + if (current == null) { return Array.Empty(); } @@ -184,7 +184,7 @@ public IReadOnlyCollection GetCurrentAlterationRules() .Select(r => r.Action.GetType()) .ToHashSet(); - return currentRules + return current.AllRules .Where(r => !fixedRuleActions.Contains(r.Action.GetType())) .ToList() .AsReadOnly(); @@ -195,17 +195,10 @@ public async ValueTask EnforceRules( Connection? connection = null, Exchange? exchange = null) { try { - foreach (var rule in _effectiveRules!) - { - if (rule.Action.ActionScope != filterScope && - rule.Action.ActionScope != FilterScope.OutOfScope && - !(rule.Action.ActionScope == FilterScope.CopySibling && - rule.Action is MultipleScopeAction multipleScopeAction && - multipleScopeAction.RunScope == filterScope)) - { - continue; - } + var rules = _partitionedRules!.GetRulesForScope(filterScope); + foreach (var rule in rules) + { await rule.Enforce( context, exchange, connection, filterScope, ExecutionContext?.BreakPointManager!).ConfigureAwait(false); @@ -223,7 +216,7 @@ await rule.Enforce(context, exchange, connection, filterScope, if (e is RuleExecutionFailureException) { throw; } - + throw new RuleExecutionFailureException("Error while evaluating rules: " + e.Message, e); } diff --git a/test/Fluxzy.Tests/UnitTests/Core/PartitionedRulesTests.cs b/test/Fluxzy.Tests/UnitTests/Core/PartitionedRulesTests.cs new file mode 100644 index 00000000..d17f2e8d --- /dev/null +++ b/test/Fluxzy.Tests/UnitTests/Core/PartitionedRulesTests.cs @@ -0,0 +1,185 @@ +// Copyright 2021 - Haga Rakotoharivelo - https://github.com/haga-rak + +using System.Linq; +using Fluxzy.Core; +using Fluxzy.Rules; +using Fluxzy.Rules.Actions; +using Fluxzy.Rules.Filters; +using Xunit; + +namespace Fluxzy.Tests.UnitTests.Core +{ + public class PartitionedRulesTests + { + [Fact] + public void DirectScopeMatch_RuleLandsInCorrectBucket() + { + var rule = new Rule( + new AddRequestHeaderAction("X-Test", "v"), AnyFilter.Default); + + var partitioned = new PartitionedRules(new[] { rule }); + + Assert.Contains(rule, + partitioned.GetRulesForScope(FilterScope.RequestHeaderReceivedFromClient)); + Assert.DoesNotContain(rule, + partitioned.GetRulesForScope(FilterScope.OnAuthorityReceived)); + Assert.DoesNotContain(rule, + partitioned.GetRulesForScope(FilterScope.ResponseHeaderReceivedFromRemote)); + } + + [Fact] + public void OutOfScope_RuleAppearsInAllPipelineBuckets() + { + var rule = new Rule(new StdOutAction("test"), AnyFilter.Default); + + var partitioned = new PartitionedRules(new[] { rule }); + + Assert.Contains(rule, partitioned.GetRulesForScope(FilterScope.OnAuthorityReceived)); + Assert.Contains(rule, partitioned.GetRulesForScope(FilterScope.RequestHeaderReceivedFromClient)); + Assert.Contains(rule, partitioned.GetRulesForScope(FilterScope.DnsSolveDone)); + Assert.Contains(rule, partitioned.GetRulesForScope(FilterScope.RequestBodyReceivedFromClient)); + Assert.Contains(rule, partitioned.GetRulesForScope(FilterScope.ResponseHeaderReceivedFromRemote)); + Assert.Contains(rule, partitioned.GetRulesForScope(FilterScope.ResponseBodyReceivedFromRemote)); + } + + [Fact] + public void MultipleScopeAction_WithRunScope_LandsInRunScopeBucket() + { + var action = new StdErrAction("test") { + RunScope = FilterScope.ResponseHeaderReceivedFromRemote + }; + var rule = new Rule(action, AnyFilter.Default); + + var partitioned = new PartitionedRules(new[] { rule }); + + Assert.Contains(rule, + partitioned.GetRulesForScope(FilterScope.ResponseHeaderReceivedFromRemote)); + Assert.DoesNotContain(rule, + partitioned.GetRulesForScope(FilterScope.OnAuthorityReceived)); + Assert.DoesNotContain(rule, + partitioned.GetRulesForScope(FilterScope.RequestHeaderReceivedFromClient)); + } + + [Fact] + public void MultipleScopeAction_NullRunScope_LandsInNoBucket() + { + var action = new StdErrAction("test") { RunScope = null }; + var rule = new Rule(action, AnyFilter.Default); + + var partitioned = new PartitionedRules(new[] { rule }); + + Assert.Empty(partitioned.GetRulesForScope(FilterScope.OnAuthorityReceived)); + Assert.Empty(partitioned.GetRulesForScope(FilterScope.RequestHeaderReceivedFromClient)); + Assert.Empty(partitioned.GetRulesForScope(FilterScope.DnsSolveDone)); + Assert.Empty(partitioned.GetRulesForScope(FilterScope.RequestBodyReceivedFromClient)); + Assert.Empty(partitioned.GetRulesForScope(FilterScope.ResponseHeaderReceivedFromRemote)); + Assert.Empty(partitioned.GetRulesForScope(FilterScope.ResponseBodyReceivedFromRemote)); + } + + [Fact] + public void OrderPreserved_WithinScopeBucket() + { + var rule1 = new Rule( + new AddRequestHeaderAction("X-First", "1"), AnyFilter.Default); + var rule2 = new Rule( + new AddRequestHeaderAction("X-Second", "2"), AnyFilter.Default); + var rule3 = new Rule( + new AddRequestHeaderAction("X-Third", "3"), AnyFilter.Default); + + var partitioned = new PartitionedRules(new[] { rule1, rule2, rule3 }); + + var bucket = partitioned.GetRulesForScope( + FilterScope.RequestHeaderReceivedFromClient); + + Assert.Equal(3, bucket.Length); + Assert.Same(rule1, bucket[0]); + Assert.Same(rule2, bucket[1]); + Assert.Same(rule3, bucket[2]); + } + + [Fact] + public void AllRules_ContainsCompleteList() + { + var ruleA = new Rule( + new SkipSslTunnelingAction(), AnyFilter.Default); + var ruleB = new Rule( + new AddResponseHeaderAction("X-Test", "v"), AnyFilter.Default); + + var partitioned = new PartitionedRules(new[] { ruleA, ruleB }); + + Assert.Equal(2, partitioned.AllRules.Count); + Assert.Same(ruleA, partitioned.AllRules[0]); + Assert.Same(ruleB, partitioned.AllRules[1]); + } + + [Fact] + public void GetRulesForScope_UnknownScope_ReturnsEmpty() + { + var rule = new Rule( + new AddRequestHeaderAction("X-Test", "v"), AnyFilter.Default); + + var partitioned = new PartitionedRules(new[] { rule }); + + Assert.Empty(partitioned.GetRulesForScope(FilterScope.CopySibling)); + Assert.Empty(partitioned.GetRulesForScope(FilterScope.OutOfScope)); + } + + [Fact] + public void MixedRules_CorrectPartitioning() + { + var authorityRule = new Rule( + new SkipSslTunnelingAction(), AnyFilter.Default); + var requestRule = new Rule( + new AddRequestHeaderAction("X-Req", "v"), AnyFilter.Default); + var responseRule = new Rule( + new AddResponseHeaderAction("X-Res", "v"), AnyFilter.Default); + var outOfScopeRule = new Rule( + new StdOutAction("log"), AnyFilter.Default); + var multiScopeRule = new Rule( + new StdErrAction("err") { RunScope = FilterScope.DnsSolveDone }, + AnyFilter.Default); + + var allRules = new[] { + authorityRule, requestRule, responseRule, outOfScopeRule, multiScopeRule + }; + + var partitioned = new PartitionedRules(allRules); + + // OnAuthorityReceived: authorityRule + outOfScopeRule + var authority = partitioned.GetRulesForScope(FilterScope.OnAuthorityReceived); + Assert.Equal(2, authority.Length); + Assert.Same(authorityRule, authority[0]); + Assert.Same(outOfScopeRule, authority[1]); + + // RequestHeaderReceivedFromClient: requestRule + outOfScopeRule + var request = partitioned.GetRulesForScope(FilterScope.RequestHeaderReceivedFromClient); + Assert.Equal(2, request.Length); + Assert.Same(requestRule, request[0]); + Assert.Same(outOfScopeRule, request[1]); + + // DnsSolveDone: outOfScopeRule + multiScopeRule + var dns = partitioned.GetRulesForScope(FilterScope.DnsSolveDone); + Assert.Equal(2, dns.Length); + Assert.Same(outOfScopeRule, dns[0]); + Assert.Same(multiScopeRule, dns[1]); + + // ResponseHeaderReceivedFromRemote: responseRule + outOfScopeRule + var response = partitioned.GetRulesForScope(FilterScope.ResponseHeaderReceivedFromRemote); + Assert.Equal(2, response.Length); + Assert.Same(responseRule, response[0]); + Assert.Same(outOfScopeRule, response[1]); + } + + [Fact] + public void EmptyRuleList_AllBucketsEmpty() + { + var partitioned = new PartitionedRules(System.Array.Empty()); + + Assert.Empty(partitioned.GetRulesForScope(FilterScope.OnAuthorityReceived)); + Assert.Empty(partitioned.GetRulesForScope(FilterScope.RequestHeaderReceivedFromClient)); + Assert.Empty(partitioned.GetRulesForScope(FilterScope.DnsSolveDone)); + Assert.Empty(partitioned.GetRulesForScope(FilterScope.ResponseHeaderReceivedFromRemote)); + Assert.Empty(partitioned.AllRules); + } + } +} From eb7f0cb22e1b7ee8b6edbe4792fd7904584ec417 Mon Sep 17 00:00:00 2001 From: haga-rak Date: Sun, 12 Apr 2026 22:01:08 +0200 Subject: [PATCH 22/26] Optimize Header.WriteHttp11 hot path - Drop Encoding.ASCII dispatches for constant literals, use u8 spans - Sum byte length directly from char length in GetHttp11LengthOnly - Add int-keyed status line byte map, format status code via Utf8Formatter to remove StatusCode.ToString() allocation per response --- .../H2/Encoder/Utils/Http11Constants.cs | 37 ++++++++++++++ src/Fluxzy.Core/Core/Header.cs | 47 +++++++++-------- src/Fluxzy.Core/Core/RequestHeader.cs | 29 +++++------ src/Fluxzy.Core/Core/ResponseHeader.cs | 50 +++++++++++++------ 4 files changed, 109 insertions(+), 54 deletions(-) diff --git a/src/Fluxzy.Core/Clients/H2/Encoder/Utils/Http11Constants.cs b/src/Fluxzy.Core/Clients/H2/Encoder/Utils/Http11Constants.cs index 9f412732..40a87942 100644 --- a/src/Fluxzy.Core/Clients/H2/Encoder/Utils/Http11Constants.cs +++ b/src/Fluxzy.Core/Clients/H2/Encoder/Utils/Http11Constants.cs @@ -73,6 +73,28 @@ public sealed class Http11Constants StatusLineMappingStr.ToDictionary(t => t.Key.AsMemory(), t => t.Value.AsMemory(), new SpanCharactersIgnoreCaseComparer()); + private static readonly byte[]?[] StatusLineBytesByCode = BuildStatusLineBytesArray(); + + private static readonly byte[] UnknownStatusBytes = "Unknown status"u8.ToArray(); + + private static byte[]?[] BuildStatusLineBytesArray() + { + var max = 0; + + foreach (var key in StatusLineMappingStr.Keys) { + var code = int.Parse(key); + if (code > max) max = code; + } + + var arr = new byte[]?[max + 1]; + + foreach (var kv in StatusLineMappingStr) { + arr[int.Parse(kv.Key)] = System.Text.Encoding.ASCII.GetBytes(kv.Value); + } + + return arr; + } + public static readonly byte [] DoubleCrLf = new byte[] {13, 10, 13, 10}; @@ -134,6 +156,21 @@ public static ReadOnlyMemory GetStatusLine(ReadOnlyMemory statusCode return "Unknown status".AsMemory(); } + public static ReadOnlySpan GetStatusLineBytes(int statusCode) + { + var arr = StatusLineBytesByCode; + + if ((uint) statusCode < (uint) arr.Length) { + var val = arr[statusCode]; + + if (val != null) { + return val; + } + } + + return UnknownStatusBytes; + } + public static bool IsNonForwardableHeader(ReadOnlyMemory headerName) { return NonH2Header.Contains(headerName); diff --git a/src/Fluxzy.Core/Core/Header.cs b/src/Fluxzy.Core/Core/Header.cs index 4ab2d60f..74f04f31 100644 --- a/src/Fluxzy.Core/Core/Header.cs +++ b/src/Fluxzy.Core/Core/Header.cs @@ -185,13 +185,11 @@ public override string ToString() public int GetHttp11LengthOnly(bool skipNonForwardableHeader, bool shouldClose, bool plainHttp) { - var totalLength = 0; - // Writing Method Path Http Protocol Version - totalLength += GetHeaderLineLength(plainHttp); + var totalLength = GetHeaderLineLength(plainHttp); foreach (var header in _rawHeaderFields) { - if (header.Name.Span[0] == ':') // H2 control header + if (header.Name.Span[0] == ':') // H2 control header { continue; } @@ -200,13 +198,11 @@ public int GetHttp11LengthOnly(bool skipNonForwardableHeader, bool shouldClose, continue; } - totalLength += Encoding.ASCII.GetByteCount(header.Name.Span); - totalLength += Encoding.ASCII.GetByteCount(": "); - totalLength += Encoding.ASCII.GetByteCount(header.Value.Span); - totalLength += Encoding.ASCII.GetByteCount("\r\n"); + // ASCII: 1 char == 1 byte. Constants ": " (2) and "\r\n" (2) folded in. + totalLength += header.Name.Length + header.Value.Length + 4; } - totalLength += Encoding.ASCII.GetByteCount("\r\n"); + totalLength += 2; // final CRLF if (shouldClose) { totalLength += CloseFlatHeader.Length; // Adding connection close header @@ -233,7 +229,7 @@ public int WriteHttp11( totalLength += WriteHeaderLine(data, plainHttp); foreach (var header in _rawHeaderFields) { - if (header.Name.Span[0] == ':') // H2 control header + if (header.Name.Span[0] == ':') // H2 control header { continue; } @@ -243,9 +239,11 @@ public int WriteHttp11( } totalLength += Encoding.ASCII.GetBytes(header.Name.Span, data.Slice(totalLength)); - totalLength += Encoding.ASCII.GetBytes(": ", data.Slice(totalLength)); + ": "u8.CopyTo(data.Slice(totalLength)); + totalLength += 2; totalLength += Encoding.ASCII.GetBytes(header.Value.Span, data.Slice(totalLength)); - totalLength += Encoding.ASCII.GetBytes("\r\n", data.Slice(totalLength)); + "\r\n"u8.CopyTo(data.Slice(totalLength)); + totalLength += 2; } if (requestClose) { @@ -253,7 +251,8 @@ public int WriteHttp11( totalLength += CloseFlatHeader.Length; } - totalLength += Encoding.ASCII.GetBytes("\r\n", data.Slice(totalLength)); + "\r\n"u8.CopyTo(data.Slice(totalLength)); + totalLength += 2; return totalLength; } @@ -269,7 +268,7 @@ public int WriteHttp11( totalLength += WriteHeaderLine(data, plainHttp); foreach (var header in _rawHeaderFields) { - if (header.Name.Span[0] == ':') // H2 control header + if (header.Name.Span[0] == ':') // H2 control header { continue; } @@ -279,9 +278,11 @@ public int WriteHttp11( } totalLength += Encoding.ASCII.GetBytes(header.Name.Span, data.Slice(totalLength)); - totalLength += Encoding.ASCII.GetBytes(": ", data.Slice(totalLength)); + ": "u8.CopyTo(data.Slice(totalLength)); + totalLength += 2; totalLength += Encoding.ASCII.GetBytes(header.Value.Span, data.Slice(totalLength)); - totalLength += Encoding.ASCII.GetBytes("\r\n", data.Slice(totalLength)); + "\r\n"u8.CopyTo(data.Slice(totalLength)); + totalLength += 2; } if (writeKeepAlive) { @@ -289,7 +290,8 @@ public int WriteHttp11( totalLength += KeepAliveFlatHeader.Length; } - totalLength += Encoding.ASCII.GetBytes("\r\n", data.Slice(totalLength)); + "\r\n"u8.CopyTo(data.Slice(totalLength)); + totalLength += 2; return totalLength; @@ -305,7 +307,7 @@ public int WriteHttp2( // totalLength += WriteHeaderLine(data); foreach (var header in _rawHeaderFields) { - //if (header.Name.Span[0] == ':') // H2 control header + //if (header.Name.Span[0] == ':') // H2 control header // continue; if (skipNonForwardableHeader && Http11Constants.IsNonForwardableHeader(header.Name)) { @@ -313,12 +315,15 @@ public int WriteHttp2( } totalLength += Encoding.ASCII.GetBytes(header.Name.Span, data.Slice(totalLength)); - totalLength += Encoding.ASCII.GetBytes(": ", data.Slice(totalLength)); + ": "u8.CopyTo(data.Slice(totalLength)); + totalLength += 2; totalLength += Encoding.ASCII.GetBytes(header.Value.Span, data.Slice(totalLength)); - totalLength += Encoding.ASCII.GetBytes("\r\n", data.Slice(totalLength)); + "\r\n"u8.CopyTo(data.Slice(totalLength)); + totalLength += 2; } - totalLength += Encoding.ASCII.GetBytes("\r\n", data.Slice(totalLength)); + "\r\n"u8.CopyTo(data.Slice(totalLength)); + totalLength += 2; return totalLength; } diff --git a/src/Fluxzy.Core/Core/RequestHeader.cs b/src/Fluxzy.Core/Core/RequestHeader.cs index 74654e64..9a3890f8 100644 --- a/src/Fluxzy.Core/Core/RequestHeader.cs +++ b/src/Fluxzy.Core/Core/RequestHeader.cs @@ -99,37 +99,32 @@ protected override int WriteHeaderLine(Span buffer, bool plainHttp) var totalLength = 0; totalLength += Encoding.ASCII.GetBytes(Method.Span, buffer.Slice(totalLength)); - totalLength += Encoding.ASCII.GetBytes(" ", buffer.Slice(totalLength)); + buffer[totalLength++] = (byte) ' '; var path = !plainHttp ? Path.Span : PathAndQueryUtility.Parse(Path.Span); totalLength += Encoding.ASCII.GetBytes(path, buffer.Slice(totalLength)); - totalLength += Encoding.ASCII.GetBytes(" HTTP/1.1\r\n", buffer.Slice(totalLength)); - totalLength += Encoding.ASCII.GetBytes("Host: ", buffer.Slice(totalLength)); + // " HTTP/1.1\r\nHost: " = 17 bytes + " HTTP/1.1\r\nHost: "u8.CopyTo(buffer.Slice(totalLength)); + totalLength += 17; + totalLength += Encoding.ASCII.GetBytes(Authority.Span, buffer.Slice(totalLength)); - totalLength += Encoding.ASCII.GetBytes("\r\n", buffer.Slice(totalLength)); + + "\r\n"u8.CopyTo(buffer.Slice(totalLength)); + totalLength += 2; return totalLength; } protected override int GetHeaderLineLength(bool plainHttp) { - var totalLength = 0; - - totalLength += Encoding.ASCII.GetByteCount(Method.Span); - totalLength += Encoding.ASCII.GetByteCount(" "); - var path = !plainHttp ? Path.Span : PathAndQueryUtility.Parse(Path.Span); - totalLength += Encoding.ASCII.GetByteCount(path); - - totalLength += Encoding.ASCII.GetByteCount(" HTTP/1.1\r\n"); - totalLength += Encoding.ASCII.GetByteCount("Host: "); - totalLength += Encoding.ASCII.GetByteCount(Authority.Span); - totalLength += Encoding.ASCII.GetByteCount("\r\n"); - - return totalLength; + // Method + " " + path + " HTTP/1.1\r\n" (11) + "Host: " (6) + Authority + "\r\n" (2) + // = Method.Length + 1 + path.Length + 11 + 6 + Authority.Length + 2 + // = Method.Length + path.Length + Authority.Length + 20 + return Method.Length + path.Length + Authority.Length + 20; } } } diff --git a/src/Fluxzy.Core/Core/ResponseHeader.cs b/src/Fluxzy.Core/Core/ResponseHeader.cs index 90c4d56f..67defff3 100644 --- a/src/Fluxzy.Core/Core/ResponseHeader.cs +++ b/src/Fluxzy.Core/Core/ResponseHeader.cs @@ -1,6 +1,7 @@ // Copyright 2021 - Haga Rakotoharivelo - https://github.com/haga-rak using System; +using System.Buffers.Text; using System.Collections.Generic; using System.Linq; using System.Text; @@ -142,33 +143,50 @@ protected override int WriteHeaderLine(Span buffer, bool _) { var totalLength = 0; - var statusCodeString = StatusCode.ToString(); + // "HTTP/1.1 " = 9 bytes + "HTTP/1.1 "u8.CopyTo(buffer); + totalLength += 9; - totalLength += Encoding.ASCII.GetBytes("HTTP/1.1 ", buffer.Slice(totalLength)); - totalLength += Encoding.ASCII.GetBytes(statusCodeString, buffer.Slice(totalLength)); - totalLength += Encoding.ASCII.GetBytes(" ", buffer.Slice(totalLength)); + if (!Utf8Formatter.TryFormat(StatusCode, buffer.Slice(totalLength), out var written)) { + throw new InvalidOperationException("Failed to format status code"); + } + + totalLength += written; + + buffer[totalLength++] = (byte) ' '; - totalLength += Encoding.ASCII.GetBytes(Http11Constants.GetStatusLine(statusCodeString.AsMemory()).Span, - buffer.Slice(totalLength)); + var statusLine = Http11Constants.GetStatusLineBytes(StatusCode); + statusLine.CopyTo(buffer.Slice(totalLength)); + totalLength += statusLine.Length; - totalLength += Encoding.ASCII.GetBytes("\r\n", buffer.Slice(totalLength)); + "\r\n"u8.CopyTo(buffer.Slice(totalLength)); + totalLength += 2; return totalLength; } protected override int GetHeaderLineLength(bool _) { - var totalLength = 0; - - var statusCodeString = StatusCode.ToString(); + // "HTTP/1.1 " (9) + + " " (1) + statusLine + "\r\n" (2) + return 12 + CountDigits(StatusCode) + Http11Constants.GetStatusLineBytes(StatusCode).Length; + } - totalLength += Encoding.ASCII.GetByteCount("HTTP/1.1 "); - totalLength += Encoding.ASCII.GetByteCount(statusCodeString); - totalLength += Encoding.ASCII.GetByteCount(" "); - totalLength += Encoding.ASCII.GetByteCount(Http11Constants.GetStatusLine(statusCodeString.AsMemory()).Span); - totalLength += Encoding.ASCII.GetByteCount("\r\n"); + private static int CountDigits(int value) + { + if (value < 0) { + return CountDigits(-value) + 1; + } - return totalLength; + if (value < 10) return 1; + if (value < 100) return 2; + if (value < 1000) return 3; + if (value < 10000) return 4; + if (value < 100000) return 5; + if (value < 1000000) return 6; + if (value < 10000000) return 7; + if (value < 100000000) return 8; + if (value < 1000000000) return 9; + return 10; } } } From a21870217eb5fee0685d34e17b05c3e3d433ce80 Mon Sep 17 00:00:00 2001 From: haga-rak Date: Sun, 12 Apr 2026 22:13:37 +0200 Subject: [PATCH 23/26] Statik buffer for benchmark server --- test/Fluxzy.Benchmarks.Server/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/Fluxzy.Benchmarks.Server/Program.cs b/test/Fluxzy.Benchmarks.Server/Program.cs index fd53a653..4f7a1e8f 100644 --- a/test/Fluxzy.Benchmarks.Server/Program.cs +++ b/test/Fluxzy.Benchmarks.Server/Program.cs @@ -27,6 +27,7 @@ }); var app = builder.Build(); +var buffer = new byte[16384 * 4]; app.MapGet("/bench", async ctx => { @@ -36,7 +37,6 @@ if (length > 0) { ctx.Response.ContentLength = 8192; - var buffer = new byte[Math.Min(length, 16384)]; var remaining = length; while (remaining > 0) From 88485276c80cda2fc79fa6cb8d96ecb598dd65dc Mon Sep 17 00:00:00 2001 From: haga-rak Date: Sun, 12 Apr 2026 23:13:49 +0200 Subject: [PATCH 24/26] Add allocation profiling: --alloc flag and TraceAllocationAnalyzer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parallel to the existing --contention flag, add an opt-in FLUXZY_BENCH_ALLOC path that captures CLR GC/AllocationTick events with managed stacks into a .nettrace per benchmark case. Ships with TraceAllocationAnalyzer, a small TraceEvent-based CLI that aggregates the events by type, top frame, and first Fluxzy frame — the allocation view BenchmarkDotNet's speedscope export doesn't provide. --- benchmark-throughput.cmd | 10 + benchmark-throughput.sh | 11 + .../ProxyThroughputBenchmark.cs | 32 ++- tools/TraceAllocationAnalyzer/Program.cs | 188 ++++++++++++++++++ .../TraceAllocationAnalyzer.csproj | 12 ++ tools/TraceAllocationAnalyzer/compare.py | 144 ++++++++++++++ 6 files changed, 394 insertions(+), 3 deletions(-) create mode 100644 tools/TraceAllocationAnalyzer/Program.cs create mode 100644 tools/TraceAllocationAnalyzer/TraceAllocationAnalyzer.csproj create mode 100644 tools/TraceAllocationAnalyzer/compare.py diff --git a/benchmark-throughput.cmd b/benchmark-throughput.cmd index 0be5347f..0a954e80 100644 --- a/benchmark-throughput.cmd +++ b/benchmark-throughput.cmd @@ -18,6 +18,16 @@ if /i "%~1"=="--contention" ( shift goto parse_args ) +if /i "%~1"=="--alloc" ( + rem Opt-in CLR allocation ETW trace (EventPipe). Produces .nettrace per run + rem with sampled GC/AllocationTick events + managed stacks. Defaults to + rem shorter iterations since the trace overhead skews absolute numbers. + rem Open in PerfView ("GC Heap Alloc Ignore Free (Coarse Sampling) Stacks"). + set FLUXZY_BENCH_ALLOC=1 + if "%SHORT_ARGS%"=="" set SHORT_ARGS=--warmupCount 1 --iterationCount 5 --launchCount 1 + shift + goto parse_args +) if /i "%~1"=="--h2-8k" ( rem H2 + 8192 body only, ~30%% of default duration set SHORT_ARGS=--warmupCount 2 --iterationCount 10 --launchCount 1 diff --git a/benchmark-throughput.sh b/benchmark-throughput.sh index 0e20aa92..54391e3b 100755 --- a/benchmark-throughput.sh +++ b/benchmark-throughput.sh @@ -17,6 +17,17 @@ while [[ $# -gt 0 ]]; do export FLUXZY_BENCH_CONTENTION=1 shift ;; + --alloc) + # Opt-in CLR allocation ETW trace (EventPipe). Produces .nettrace per run + # with sampled GC/AllocationTick events + managed stacks. Defaults to + # shorter iterations since the trace overhead skews absolute numbers. + # Open in PerfView ("GC Heap Alloc Ignore Free (Coarse Sampling) Stacks"). + export FLUXZY_BENCH_ALLOC=1 + if [[ -z "$SHORT_ARGS" ]]; then + SHORT_ARGS="--warmupCount 1 --iterationCount 5 --launchCount 1" + fi + shift + ;; --h2-8k) # H2 + 8192 body only, ~30% of default duration SHORT_ARGS="--warmupCount 2 --iterationCount 5 --launchCount 1" diff --git a/test/Fluxzy.Benchmarks/ProxyThroughputBenchmark.cs b/test/Fluxzy.Benchmarks/ProxyThroughputBenchmark.cs index f51de81e..423d6cf6 100644 --- a/test/Fluxzy.Benchmarks/ProxyThroughputBenchmark.cs +++ b/test/Fluxzy.Benchmarks/ProxyThroughputBenchmark.cs @@ -174,10 +174,12 @@ private async Task SendRequest() private class Config : ManualConfig { // CLR ETW keywords — values come from Microsoft-Windows-DotNETRuntime provider manifest. - // Combined, these give us ContentionStart/Stop with resolvable managed call stacks. - private const long ClrContentionKeyword = 0x4000; // GC=0x1, Loader=0x8, Jit=0x10, Contention=0x4000 - private const long ClrJitKeyword = 0x10; + private const long ClrGcKeyword = 0x1; // GC/AllocationTick, GCHeapStats, GCTriggered + private const long ClrGcHandleKeyword = 0x2; // GC handle traffic (pinning, weak refs) private const long ClrLoaderKeyword = 0x8; + private const long ClrJitKeyword = 0x10; + private const long ClrContentionKeyword = 0x4000; + private const long ClrTypeKeyword = 0x80000; // type name resolution for alloc events private const long ClrJitToNativeMapKeyword = 0x20000; private const long ClrStackKeyword = 0x40000000; @@ -206,6 +208,30 @@ public Config() AddDiagnoser(new EventPipeProfiler(providers: providers)); } + + // Opt-in allocation trace: FLUXZY_BENCH_ALLOC=1 produces a .nettrace per benchmark run + // with sampled GC/AllocationTick events (~every 100 KB of allocations) and managed + // call stacks. Open in PerfView ("GC Heap Alloc Ignore Free (Coarse Sampling) Stacks"), + // Visual Studio, or convert with `dotnet-trace convert --format speedscope *.nettrace`. + if (string.Equals( + Environment.GetEnvironmentVariable("FLUXZY_BENCH_ALLOC"), + "1", + StringComparison.Ordinal)) { + var providers = new[] { + new EventPipeProvider( + name: "Microsoft-Windows-DotNETRuntime", + eventLevel: EventLevel.Verbose, + keywords: ClrGcKeyword + | ClrGcHandleKeyword + | ClrTypeKeyword + | ClrJitKeyword + | ClrLoaderKeyword + | ClrJitToNativeMapKeyword + | ClrStackKeyword) + }; + + AddDiagnoser(new EventPipeProfiler(providers: providers)); + } } } diff --git a/tools/TraceAllocationAnalyzer/Program.cs b/tools/TraceAllocationAnalyzer/Program.cs new file mode 100644 index 00000000..9f4f149a --- /dev/null +++ b/tools/TraceAllocationAnalyzer/Program.cs @@ -0,0 +1,188 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using Microsoft.Diagnostics.Tracing.Etlx; +using Microsoft.Diagnostics.Tracing.Parsers.Clr; + +namespace TraceAllocationAnalyzer; + +/// +/// Loads a .nettrace captured with Microsoft-Windows-DotNETRuntime GC+Type+Stack +/// keywords (Verbose) and aggregates GC/AllocationTick events. +/// +/// Each AllocationTick event represents ~100 KB of allocations since the last tick +/// on the same thread, so we weight each event as 100 KB for total-bytes estimates +/// (this matches PerfView's "GC Heap Alloc Ignore Free (Coarse Sampling)" view). +/// +/// Produces three roll-ups per trace: +/// 1. By managed type (who allocates the most bytes) +/// 2. By top managed frame (nearest user/framework frame on the stack) +/// 3. By Fluxzy frame (first frame in Fluxzy.* — the hotspot inside our code) +/// +internal static class Program +{ + private const long BytesPerTick = 100_000; // AllocationTick samples ~every 100 KB + private const int DefaultTop = 20; + private const int DefaultStackDepth = 40; + + private static int Main(string[] args) + { + if (args.Length < 1) { + Console.Error.WriteLine("Usage: TraceAllocationAnalyzer [top=20] [stackDepth=40]"); + return 2; + } + + var tracePath = args[0]; + var top = args.Length >= 2 ? int.Parse(args[1]) : DefaultTop; + var stackDepth = args.Length >= 3 ? int.Parse(args[2]) : DefaultStackDepth; + + if (!File.Exists(tracePath)) { + Console.Error.WriteLine($"File not found: {tracePath}"); + return 2; + } + + Console.Error.WriteLine($"Indexing {Path.GetFileName(tracePath)} ..."); + var etlxPath = TraceLog.CreateFromEventPipeDataFile(tracePath); + + try { + using var traceLog = new TraceLog(etlxPath); + Analyze(traceLog, Path.GetFileName(tracePath), top, stackDepth); + } + finally { + try { File.Delete(etlxPath); } catch { /* ignore */ } + } + + return 0; + } + + private static void Analyze(TraceLog traceLog, string traceName, int top, int stackDepth) + { + var byType = new Dictionary(StringComparer.Ordinal); + var byTopFrame = new Dictionary(StringComparer.Ordinal); + var byFluxzyFrame = new Dictionary(StringComparer.Ordinal); + var byStack = new Dictionary(StringComparer.Ordinal); + + var totalEvents = 0; + var withStack = 0; + long totalAllocationBytes = 0; // sum of AllocationAmount64 (triggering-object bytes) + + var source = traceLog.Events.GetSource(); + + source.Clr.GCAllocationTick += evt => { + totalEvents++; + totalAllocationBytes += evt.AllocationAmount64; + + var typeName = string.IsNullOrEmpty(evt.TypeName) ? "" : evt.TypeName; + Add(byType, typeName, BytesPerTick); + + var callStack = evt.CallStack(); + if (callStack == null) return; + withStack++; + + var frames = new List(stackDepth); + var cursor = callStack; + var depth = 0; + + while (cursor != null && depth < stackDepth) { + var method = cursor.CodeAddress.FullMethodName; + if (!string.IsNullOrEmpty(method)) + frames.Add(method); + cursor = cursor.Caller; + depth++; + } + + if (frames.Count == 0) return; + + // Nearest meaningful managed frame (top of stack, skipping empty names). + Add(byTopFrame, frames[0], BytesPerTick); + + // First Fluxzy.* frame — the user-code hotspot responsible for the allocation. + var fluxzy = frames.FirstOrDefault(f => + f.StartsWith("Fluxzy.", StringComparison.Ordinal) || + f.StartsWith("fluxzy!", StringComparison.Ordinal) || + f.Contains("!Fluxzy.", StringComparison.Ordinal)); + + if (fluxzy != null) + Add(byFluxzyFrame, fluxzy, BytesPerTick); + + var key = string.Join("\n", frames.Take(8)); // 8-deep stack fingerprint + if (!byStack.TryGetValue(key, out var agg)) { + agg = new StackAggregate { Frames = frames, TypeName = typeName }; + byStack[key] = agg; + } + agg.Bytes += BytesPerTick; + agg.Count++; + }; + + source.Process(); + + var estimatedTotal = (long) totalEvents * BytesPerTick; + + Console.WriteLine(); + Console.WriteLine($"========== {traceName} =========="); + Console.WriteLine($"AllocationTick events : {totalEvents:N0}"); + Console.WriteLine($"With stack : {withStack:N0} ({Pct(withStack, totalEvents)})"); + Console.WriteLine($"Estimated bytes (×100K): {FormatBytes(estimatedTotal)}"); + Console.WriteLine($"Sum AllocationAmount64 : {FormatBytes(totalAllocationBytes)} (size of triggering objects)"); + Console.WriteLine(); + + PrintTop("Top allocated types", byType, estimatedTotal, top); + PrintTop("Top allocating frames (nearest managed frame)", byTopFrame, estimatedTotal, top); + PrintTop("Top Fluxzy frames (first Fluxzy.* on stack)", byFluxzyFrame, estimatedTotal, top); + PrintTopStacks("Top stacks (8-deep)", byStack, estimatedTotal, Math.Min(top, 10)); + } + + private static void PrintTop(string title, Dictionary map, long totalBytes, int top) + { + Console.WriteLine($"--- {title} ---"); + Console.WriteLine($"{"Bytes",12} {"%",6} Name"); + foreach (var kv in map.OrderByDescending(kv => kv.Value).Take(top)) { + Console.WriteLine($"{FormatBytes(kv.Value),12} {Pct(kv.Value, totalBytes),6} {kv.Key}"); + } + Console.WriteLine(); + } + + private static void PrintTopStacks(string title, Dictionary map, long totalBytes, int top) + { + Console.WriteLine($"--- {title} ---"); + var rank = 0; + foreach (var agg in map.Values.OrderByDescending(a => a.Bytes).Take(top)) { + rank++; + Console.WriteLine($"#{rank} bytes={FormatBytes(agg.Bytes)} count={agg.Count:N0} ({Pct(agg.Bytes, totalBytes)}) type={agg.TypeName}"); + foreach (var f in agg.Frames.Take(8)) + Console.WriteLine($" {f}"); + Console.WriteLine(); + } + } + + private static void Add(Dictionary map, string key, long value) + { + if (!map.TryGetValue(key, out var cur)) cur = 0; + map[key] = cur + value; + } + + private static string Pct(long a, long b) + => b <= 0 ? "-" : (100d * a / b).ToString("F1", CultureInfo.InvariantCulture) + "%"; + + private static string Pct(int a, int b) + => b <= 0 ? "-" : (100d * a / b).ToString("F1", CultureInfo.InvariantCulture) + "%"; + + private static string FormatBytes(long bytes) + { + double v = bytes; + string[] u = { "B", "KB", "MB", "GB" }; + var i = 0; + while (v >= 1024 && i < u.Length - 1) { v /= 1024; i++; } + return v.ToString("F2", CultureInfo.InvariantCulture) + " " + u[i]; + } + + private sealed class StackAggregate + { + public long Bytes; + public int Count; + public string TypeName = ""; + public List Frames = new(); + } +} diff --git a/tools/TraceAllocationAnalyzer/TraceAllocationAnalyzer.csproj b/tools/TraceAllocationAnalyzer/TraceAllocationAnalyzer.csproj new file mode 100644 index 00000000..37a0ad3a --- /dev/null +++ b/tools/TraceAllocationAnalyzer/TraceAllocationAnalyzer.csproj @@ -0,0 +1,12 @@ + + + Exe + latest + TraceAllocationAnalyzer + TraceAllocationAnalyzer + false + + + + + diff --git a/tools/TraceAllocationAnalyzer/compare.py b/tools/TraceAllocationAnalyzer/compare.py new file mode 100644 index 00000000..8b5091e6 --- /dev/null +++ b/tools/TraceAllocationAnalyzer/compare.py @@ -0,0 +1,144 @@ +"""Side-by-side comparison of the 4 TraceAllocationAnalyzer outputs. + +Usage: python compare.py +Expects files: alloc-h1-0.txt, alloc-h1-8k.txt, alloc-h2-0.txt, alloc-h2-8k.txt +""" + +import re +import sys +from pathlib import Path + +CASES = [("h1-0", "H1/0"), ("h1-8k", "H1/8k"), ("h2-0", "H2/0"), ("h2-8k", "H2/8k")] +SECTIONS = ["Top allocated types", "Top Fluxzy frames (first Fluxzy.* on stack)"] + +UNIT_MULT = {"B": 1, "KB": 1024, "MB": 1024 ** 2, "GB": 1024 ** 3} + + +def to_bytes(s: str) -> int: + m = re.match(r"([\d.]+)\s*(B|KB|MB|GB)", s) + if not m: + return 0 + return int(float(m.group(1)) * UNIT_MULT[m.group(2)]) + + +def fmt_bytes(n: int) -> str: + v = float(n) + for u in ("B", "KB", "MB", "GB"): + if v < 1024 or u == "GB": + return f"{v:.2f} {u}" + v /= 1024 + return f"{v:.2f} GB" + + +def parse(path: Path) -> dict: + """Return {section_title: {name: bytes}} + header metadata.""" + text = path.read_text(encoding="utf-8", errors="replace") + result = {"meta": {}, "sections": {}} + + m = re.search(r"Estimated bytes \(×100K\)\s*:\s*([\d.]+\s*[A-Z]+)", text) + if m: + result["meta"]["total"] = to_bytes(m.group(1)) + m = re.search(r"AllocationTick events\s*:\s*([\d ,]+)", text) + if m: + result["meta"]["events"] = int(m.group(1).replace(" ", "").replace(",", "")) + + for section in SECTIONS: + # Each section is: "--- ---" then header line "Bytes % Name" then rows + pat = re.compile( + r"---\s*" + re.escape(section) + r"\s*---\s*\n" + r"\s*Bytes\s+%\s+Name\s*\n" + r"((?:.+\n)+?)(?=\n|---)", + re.MULTILINE, + ) + m = pat.search(text) + if not m: + continue + rows = {} + for line in m.group(1).splitlines(): + line = line.rstrip() + if not line.strip(): + break + row_m = re.match(r"\s*([\d.]+\s+(?:B|KB|MB|GB))\s+([\d.]+%|-)?\s+(.*)", line) + if not row_m: + continue + bytes_val = to_bytes(row_m.group(1)) + name = row_m.group(3).strip() + rows[name] = bytes_val + result["sections"][section] = rows + + return result + + +def shorten(name: str) -> str: + # Strip common prefixes and signatures + name = re.sub(r"\([^)]*\)", "()", name) + name = name.replace("System.Runtime.CompilerServices.", "SRC.") + name = name.replace("System.Threading.Tasks.", "STT.") + name = name.replace("System.Collections.Generic.", "SCG.") + name = name.replace("System.Net.Http.", "SNH.") + name = name.replace("Fluxzy.", "F.") + return name + + +def print_section(section: str, parsed: dict, top_n: int = 20): + print(f"\n### {section}") + print() + # Collect union of top-N names across all cases + names_union = set() + for _, label in CASES: + rows = parsed[label]["sections"].get(section, {}) + for name, _ in sorted(rows.items(), key=lambda x: -x[1])[:top_n]: + names_union.add(name) + + # Rank by max value across cases + def max_across(name): + return max(parsed[label]["sections"].get(section, {}).get(name, 0) for _, label in CASES) + + ranked = sorted(names_union, key=max_across, reverse=True) + + totals = {label: parsed[label]["meta"].get("total", 0) for _, label in CASES} + header = f"| {'name':<80} |" + for _, label in CASES: + header += f" {label:>10} |" + print(header) + print("|" + "-" * 82 + "|" + ("|".join(["-" * 12] * 4)) + "|") + + for name in ranked: + short = shorten(name)[:80] + row = f"| {short:<80} |" + for _, label in CASES: + b = parsed[label]["sections"].get(section, {}).get(name, 0) + t = totals[label] + pct = (100 * b / t) if t else 0 + cell = f"{fmt_bytes(b)} ({pct:.1f}%)" + row += f" {cell:>10} |" + print(row) + + +def main(): + if len(sys.argv) != 2: + print(__doc__) + sys.exit(2) + directory = Path(sys.argv[1]) + + parsed = {} + for key, label in CASES: + f = directory / f"alloc-{key}.txt" + if not f.exists(): + print(f"Missing: {f}") + sys.exit(1) + parsed[label] = parse(f) + + print("# Allocation comparison across 4 benchmark cases\n") + print("## Totals (estimated, AllocationTick × 100KB)\n") + print("| case | total bytes | events |") + print("|------|-------------|--------|") + for _, label in CASES: + print(f"| {label} | {fmt_bytes(parsed[label]['meta']['total'])} | {parsed[label]['meta']['events']:,} |") + + for section in SECTIONS: + print_section(section, parsed, top_n=20) + + +if __name__ == "__main__": + main() From 23df33908609ffe2c29292da587ce0730d6265c4 Mon Sep 17 00:00:00 2001 From: haga-rak <haga.rakotoharivelo@gmail.com> Date: Sun, 12 Apr 2026 23:15:02 +0200 Subject: [PATCH 25/26] Pool H2 header accumulation buffer in StreamWorker and ServerStreamWorker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both workers allocated a fresh byte[MaxHeaderSize] per stream (16 KB default) to accumulate HEADERS/CONTINUATION fragments before HPACK decode. Allocation sampling showed this was the #1 byte[] allocator across all four throughput benchmark cases (28-48% of bytes, even on the H1 downstream path since upstream is ALPN-negotiated to H2). Route through ArrayPool<byte>.Shared: rent on first fragment (with grow for oversize responses in StreamWorker), return on Dispose. The decoded headers (H2Helper.DecodeAndAllocate, DecodeTrailerFields) produce fresh char buffers and HeaderField lists — no aliasing back to the pooled bytes, so returning at stream end is safe. Regression test H2LargeHeaderTests exercises the grow path (~30 KB response headers across 20 sequential requests) and the fast-path rent/return churn (50 sequential requests) to catch double-return or prefix-copy bugs. --- src/Fluxzy.Core/Clients/H2/StreamWorker.cs | 86 ++++----- src/Fluxzy.Core/Core/ServerStreamWorker.cs | 5 +- test/Fluxzy.Tests/Cases/H2LargeHeaderTests.cs | 165 ++++++++++++++++++ 3 files changed, 213 insertions(+), 43 deletions(-) create mode 100644 test/Fluxzy.Tests/Cases/H2LargeHeaderTests.cs diff --git a/src/Fluxzy.Core/Clients/H2/StreamWorker.cs b/src/Fluxzy.Core/Clients/H2/StreamWorker.cs index 2c9b3da5..50cd3b6d 100644 --- a/src/Fluxzy.Core/Clients/H2/StreamWorker.cs +++ b/src/Fluxzy.Core/Clients/H2/StreamWorker.cs @@ -26,7 +26,7 @@ internal sealed class StreamWorker : IDisposable private bool _disposed; private bool _firstBodyFragment = true; - private Memory<byte> _headerBuffer; + private byte[]? _headerBuffer; private bool _headerEndedStream; private bool _noBodyStream; @@ -85,6 +85,11 @@ public void Dispose() RemoteWindowSize?.Dispose(); + if (_headerBuffer != null) { + ArrayPool<byte>.Shared.Return(_headerBuffer); + _headerBuffer = null; + } + try { _headerReceivedSemaphore.Release(); _headerReceivedSemaphore.Dispose(); @@ -274,6 +279,38 @@ public async ValueTask ProcessRequestBody(Exchange exchange, RsBuffer buffer, Ca } } + /// <summary> + /// Ensure <see cref="_headerBuffer"/> is rented from <see cref="ArrayPool{T}.Shared"/> + /// and has at least <paramref name="requiredLength"/> capacity. Shared between the + /// response-header and trailer accumulation paths. Grows by renting a new array, + /// copying the existing prefix, and returning the old one. + /// </summary> + private void EnsureHeaderBuffer(int requiredLength) + { + if (_headerBuffer == null) { + var initial = Math.Max(requiredLength, Parent.Context.Setting.MaxHeaderSize); + _headerBuffer = ArrayPool<byte>.Shared.Rent(initial); + return; + } + + if (requiredLength <= _headerBuffer.Length) + return; + + // Grow up to the negotiated MaxHeaderListSize + var maxAllowed = Parent.Context.Setting.Local.MaxHeaderListSize; + + if (requiredLength > maxAllowed) + throw new H2Exception( + $"Response header size ({requiredLength}) exceeds negotiated maximum ({maxAllowed})", + H2ErrorCode.FrameSizeError); + + var newSize = Math.Min(Math.Max(requiredLength, _headerBuffer.Length * 2), maxAllowed); + var newBuffer = ArrayPool<byte>.Shared.Rent(newSize); + _headerBuffer.AsSpan(0, _totalHeaderReceived).CopyTo(newBuffer); + ArrayPool<byte>.Shared.Return(_headerBuffer); + _headerBuffer = newBuffer; + } + internal void ReceiveHeaderFragmentFromConnection(ref HeadersFrame headerFrame) { _exchange.Metrics.TotalReceived += headerFrame.BodyLength; @@ -311,33 +348,15 @@ private void ReceiveHeaderFragmentFromConnection( ReadOnlyMemory<byte> buffer, bool lastHeaderFragment) { - if (_headerBuffer.IsEmpty) - _headerBuffer = new byte[Parent.Context.Setting.MaxHeaderSize]; - - var futureLength = _totalHeaderReceived + buffer.Length; - - if (futureLength > _headerBuffer.Length) { - // Grow up to the negotiated MaxHeaderListSize - var maxAllowed = Parent.Context.Setting.Local.MaxHeaderListSize; - - if (futureLength > maxAllowed) - throw new H2Exception( - $"Response header size ({futureLength}) exceeds negotiated maximum ({maxAllowed})", - H2ErrorCode.FrameSizeError); - - var newSize = Math.Min(Math.Max(futureLength, _headerBuffer.Length * 2), maxAllowed); - var newBuffer = new byte[newSize]; - _headerBuffer.Slice(0, _totalHeaderReceived).CopyTo(newBuffer); - _headerBuffer = newBuffer; - } + EnsureHeaderBuffer(_totalHeaderReceived + buffer.Length); - buffer.CopyTo(_headerBuffer.Slice(_totalHeaderReceived)); + buffer.Span.CopyTo(_headerBuffer.AsSpan(_totalHeaderReceived)); _totalHeaderReceived += buffer.Length; if (lastHeaderFragment) { _exchange.Metrics.ResponseHeaderEnd = ITimingProvider.Default.Instant(); - var charHeader = H2Helper.DecodeAndAllocate(Parent.Context.HeaderEncoder, _headerBuffer.Slice(0, _totalHeaderReceived).Span); + var charHeader = H2Helper.DecodeAndAllocate(Parent.Context.HeaderEncoder, _headerBuffer.AsSpan(0, _totalHeaderReceived)); _exchange.Response.Header = new ResponseHeader(charHeader, true, false); @@ -372,32 +391,15 @@ private void ReceiveTrailerFragmentFromConnection( ReadOnlyMemory<byte> buffer, bool lastHeaderFragment) { - if (_headerBuffer.IsEmpty) - _headerBuffer = new byte[Parent.Context.Setting.MaxHeaderSize]; - - var futureLength = _totalHeaderReceived + buffer.Length; - - if (futureLength > _headerBuffer.Length) { - var maxAllowed = Parent.Context.Setting.Local.MaxHeaderListSize; - - if (futureLength > maxAllowed) - throw new H2Exception( - $"Response trailer size ({futureLength}) exceeds negotiated maximum ({maxAllowed})", - H2ErrorCode.FrameSizeError); - - var newSize = Math.Min(Math.Max(futureLength, _headerBuffer.Length * 2), maxAllowed); - var newBuffer = new byte[newSize]; - _headerBuffer.Slice(0, _totalHeaderReceived).CopyTo(newBuffer); - _headerBuffer = newBuffer; - } + EnsureHeaderBuffer(_totalHeaderReceived + buffer.Length); - buffer.CopyTo(_headerBuffer.Slice(_totalHeaderReceived)); + buffer.Span.CopyTo(_headerBuffer.AsSpan(_totalHeaderReceived)); _totalHeaderReceived += buffer.Length; if (lastHeaderFragment) { // Decode trailer fields directly (no HTTP/1.1 status line) var trailerFields = Parent.Context.HeaderEncoder.Decoder.DecodeTrailerFields( - _headerBuffer.Slice(0, _totalHeaderReceived).Span); + _headerBuffer.AsSpan(0, _totalHeaderReceived)); _exchange.Response.Trailers = trailerFields; diff --git a/src/Fluxzy.Core/Core/ServerStreamWorker.cs b/src/Fluxzy.Core/Core/ServerStreamWorker.cs index 055c8500..0b1cf9f6 100644 --- a/src/Fluxzy.Core/Core/ServerStreamWorker.cs +++ b/src/Fluxzy.Core/Core/ServerStreamWorker.cs @@ -1,6 +1,7 @@ // Copyright 2021 - Haga Rakotoharivelo - https://github.com/haga-rak using System; +using System.Buffers; using System.Collections.Generic; using System.IO; using System.IO.Pipelines; @@ -57,7 +58,7 @@ public ServerStreamWorker( StreamIdentifier = streamIdentifier; _headerEncoder = headerEncoder; _h2StreamSetting = h2StreamSetting; - _headerBuffer = new byte[h2StreamSetting.MaxHeaderSize]; + _headerBuffer = ArrayPool<byte>.Shared.Rent(h2StreamSetting.MaxHeaderSize); _streamWindowSizeHolder = new WindowSizeHolder(logger, h2StreamSetting.Remote.WindowSize, streamIdentifier); } @@ -275,6 +276,8 @@ public void Dispose() _requestBodyPipe?.Writer.Complete(); _streamWindowSizeHolder.Dispose(); + + ArrayPool<byte>.Shared.Return(_headerBuffer); } } diff --git a/test/Fluxzy.Tests/Cases/H2LargeHeaderTests.cs b/test/Fluxzy.Tests/Cases/H2LargeHeaderTests.cs new file mode 100644 index 00000000..079bfbfc --- /dev/null +++ b/test/Fluxzy.Tests/Cases/H2LargeHeaderTests.cs @@ -0,0 +1,165 @@ +// Copyright 2021 - Haga Rakotoharivelo - https://github.com/haga-rak + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Security; +using System.Threading.Tasks; +using Fluxzy.Rules.Actions; +using Fluxzy.Rules.Filters; +using Fluxzy.Tests._Fixtures; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Xunit; + +namespace Fluxzy.Tests.Cases; + +/// <summary> +/// Regression coverage for the ArrayPool-backed header accumulation buffer in +/// StreamWorker (client-side H2) and ServerStreamWorker (server-side H2). +/// +/// The buffers were changed from per-stream `new byte[MaxHeaderSize]` to +/// rent-from-<see cref="System.Buffers.ArrayPool{T}"/> + Return-on-Dispose. +/// The regression risks are: +/// +/// 1. Double-return / returning an already-returned buffer (silent pool corruption). +/// 2. Reading from the buffer after Dispose returned it (garbled headers). +/// 3. Grow path bug: lost bytes when re-renting a larger array and copying the prefix. +/// +/// These tests force the grow path (response headers > default 16 KB MaxHeaderSize), +/// run many sequential requests so rent/return churn is high, and verify that the +/// decoded headers the client observes match exactly what the backend sent. Any of +/// the regression modes above would surface as header corruption or missing fields. +/// </summary> +public class H2LargeHeaderTests +{ + /// <summary> + /// Backend emits ~30 KB of response headers across 30 fields — above Fluxzy's + /// default MaxHeaderSize (16 384 B) so the proxy's StreamWorker must take the + /// grow path (initial 16 KB rent → grow → copy → Return old). Runs 20 sequential + /// requests so the pool sees rent/return churn, not a one-off. Any mis-return + /// would corrupt a later request's decoded headers. + /// </summary> + [Fact] + public async Task LargeResponseHeaders_GrowPath_HeadersIntactAcrossManyRequests() + { + const int headerCount = 30; + const int headerValueLength = 1024; // ~30 KB total + + await using var host = await InProcessHost.Create(app => + { + app.MapGet("/big-headers", (HttpContext ctx) => + { + for (var i = 0; i < headerCount; i++) { + ctx.Response.Headers[$"X-Large-Header-{i:D3}"] = new string((char) ('a' + (i % 26)), headerValueLength); + } + + return Results.Ok("ok"); + }); + }, suppressLogging: true); + + var setting = FluxzySetting.CreateLocalRandomPort(); + setting.SetReverseMode(true); + setting.SetReverseModeForcedPort(host.Port); + setting.SetServeH2(true); + setting.AddAlterationRules( + new SkipRemoteCertificateValidationAction(), AnyFilter.Default); + + await using var proxy = new Proxy(setting); + var proxyPort = proxy.Run().First().Port; + + using var client = CreateClient(proxyPort); + + // Sequential to exercise rent/return cycles on a single StreamWorker lifecycle + // at a time (easier to reason about) — any double-return would immediately + // corrupt the pool and a later request would observe garbled content. + for (var iteration = 0; iteration < 20; iteration++) { + using var response = await client.GetAsync("/big-headers"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(HttpVersion.Version20, response.Version); + + for (var i = 0; i < headerCount; i++) { + var headerName = $"X-Large-Header-{i:D3}"; + var expectedChar = (char) ('a' + (i % 26)); + var expectedValue = new string(expectedChar, headerValueLength); + + Assert.True( + response.Headers.TryGetValues(headerName, out var values), + $"iteration {iteration}: missing header {headerName}"); + + var actual = values!.First(); + Assert.Equal(expectedValue.Length, actual.Length); + Assert.Equal(expectedValue, actual); + } + } + } + + /// <summary> + /// Backend emits response headers within the default 16 KB buffer (no grow path), + /// but the test runs 50 sequential requests so every iteration relies on a fresh + /// buffer rented from the shared pool. Any leak (no Return) would still pass; any + /// double-return or early Return would eventually surface as header corruption + /// because ArrayPool may hand the same array to two concurrent streams. + /// </summary> + [Fact] + public async Task ResponseHeaders_FastPathRentReturn_StableAcrossSequentialRequests() + { + await using var host = await InProcessHost.Create(app => + { + app.MapGet("/api/item/{id:int}", (int id, HttpContext ctx) => + { + ctx.Response.Headers["X-Item-Id"] = id.ToString(); + ctx.Response.Headers["X-Trace"] = new string('z', 512); + + return Results.Ok(new { id, value = "ok" }); + }); + }, suppressLogging: true); + + var setting = FluxzySetting.CreateLocalRandomPort(); + setting.SetReverseMode(true); + setting.SetReverseModeForcedPort(host.Port); + setting.SetServeH2(true); + setting.AddAlterationRules( + new SkipRemoteCertificateValidationAction(), AnyFilter.Default); + + await using var proxy = new Proxy(setting); + var proxyPort = proxy.Run().First().Port; + + using var client = CreateClient(proxyPort); + + for (var i = 0; i < 50; i++) { + using var response = await client.GetAsync($"/api/item/{i}"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(HttpVersion.Version20, response.Version); + Assert.True(response.Headers.TryGetValues("X-Item-Id", out var idValues)); + Assert.Equal(i.ToString(), idValues!.First()); + + Assert.True(response.Headers.TryGetValues("X-Trace", out var traceValues)); + Assert.Equal(new string('z', 512), traceValues!.First()); + } + } + + private static HttpClient CreateClient(int proxyPort) + { + var handler = new SocketsHttpHandler + { + SslOptions = new SslClientAuthenticationOptions + { + TargetHost = "localhost", + RemoteCertificateValidationCallback = (_, _, _, _) => true, + ApplicationProtocols = new List<SslApplicationProtocol> { SslApplicationProtocol.Http2 } + } + }; + + return new HttpClient(handler) + { + BaseAddress = new Uri($"https://localhost:{proxyPort}"), + DefaultRequestVersion = HttpVersion.Version20, + DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact, + }; + } +} From 044c6ac4ef1d89b8a142e023c110285d7e7cb142 Mon Sep 17 00:00:00 2001 From: haga-rak <haga.rakotoharivelo@gmail.com> Date: Sun, 12 Apr 2026 23:53:15 +0200 Subject: [PATCH 26/26] Replace VariableBuildingContext dictionary with allocation-free TryEvaluate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rule.Enforce creates one VariableBuildingContext per exchange per rule per scope. The constructor allocated a Dictionary<string, Func<string>>, nine Func<string> closure instances, a <>c__DisplayClass capturing state, and a resized Entry[] — roughly 1 KB/call. Allocation sampling showed this ctor as 6-12% of total bytes across all four benchmark cases, with Dictionary.Resize as a separate 3-6% line item. The only reader (VariableContext.EvaluateVariable) did dict.TryGetValue(name, out var func); return func(); so the delegate indirection was pure waste. Replace the dictionary with a switch-based TryEvaluate(string, out string) method. Each of the nine built-in variable names returns its value directly from the captured fields. Semantics are a byte-for-byte port of the prior lambda bodies, including the null-exchange fallback to string.Empty and the StatusCode > 0 guard for exchange.status. Public API break: LazyVariableEvaluations property removed. Custom variables should go through VariableContext.Set, which is the existing extensibility surface. Tests: - VariableBuildingContextTests: 17 unit tests covering each built-in, Boolean.ToString() capitalisation for authority.secure, null-exchange fallback for all exchange-scoped names, unknown-name returning false, and end-to-end interpolation via VariableContext.EvaluateVariable. - Self_Generated_Context_Variables Theory extended with exchange.path (previously uncovered). --- .../Rules/VariableBuildingContext.cs | 128 ++++++++----- src/Fluxzy.Core/Rules/VariableContext.cs | 6 +- test/Fluxzy.Tests/Cli/Variables.cs | 1 + .../Variables/VariableBuildingContextTests.cs | 181 ++++++++++++++++++ 4 files changed, 266 insertions(+), 50 deletions(-) create mode 100644 test/Fluxzy.Tests/UnitTests/Variables/VariableBuildingContextTests.cs diff --git a/src/Fluxzy.Core/Rules/VariableBuildingContext.cs b/src/Fluxzy.Core/Rules/VariableBuildingContext.cs index e47c69cd..3f66cec4 100644 --- a/src/Fluxzy.Core/Rules/VariableBuildingContext.cs +++ b/src/Fluxzy.Core/Rules/VariableBuildingContext.cs @@ -1,47 +1,81 @@ -// Copyright 2021 - Haga Rakotoharivelo - https://github.com/haga-rak - -using System; -using System.Collections.Generic; -using Fluxzy.Core; - -namespace Fluxzy.Rules -{ - public class VariableBuildingContext - { - public VariableBuildingContext( - ExchangeContext exchangeContext, - Exchange? exchange, Connection? connection, FilterScope filterScope) - { - ExchangeContext = exchangeContext; - Exchange = exchange; - Connection = connection; - - LazyVariableEvaluations = new Dictionary<string, Func<string>>(); - - LazyVariableEvaluations["authority.host"] = () => ExchangeContext.Authority.HostName; - LazyVariableEvaluations["authority.port"] = () => ExchangeContext.Authority.Port.ToString(); - LazyVariableEvaluations["authority.secure"] = () => ExchangeContext.Authority.Secure.ToString(); - - LazyVariableEvaluations["global.filterScope"] = () => filterScope.ToString(); - - LazyVariableEvaluations["exchange.id"] = () => exchange?.Id.ToString() ?? string.Empty; - LazyVariableEvaluations["exchange.url"] = () => exchange?.FullUrl ?? string.Empty; - - LazyVariableEvaluations["exchange.method"] = - () => exchange?.Request.Header.Method.ToString() ?? string.Empty; - - LazyVariableEvaluations["exchange.path"] = () => exchange?.Request.Header.Path.ToString() ?? string.Empty; - - LazyVariableEvaluations["exchange.status"] = () => - exchange?.StatusCode > 0 ? exchange.StatusCode.ToString() : string.Empty; - } - - public ExchangeContext ExchangeContext { get; } - - public Exchange? Exchange { get; } - - public Connection? Connection { get; } - - public IDictionary<string, Func<string>> LazyVariableEvaluations { get; } - } -} +// Copyright 2021 - Haga Rakotoharivelo - https://github.com/haga-rak + +using Fluxzy.Core; + +namespace Fluxzy.Rules +{ + /// <summary> + /// Per-exchange bundle of objects that feed built-in <c>${...}</c> variable + /// evaluation. Resolved by <see cref="VariableContext.EvaluateVariable"/> via + /// <see cref="TryEvaluate"/>; custom variables go through + /// <see cref="VariableContext.Set"/> instead of this class. + /// </summary> + public class VariableBuildingContext + { + private readonly FilterScope _filterScope; + + public VariableBuildingContext( + ExchangeContext exchangeContext, + Exchange? exchange, Connection? connection, FilterScope filterScope) + { + ExchangeContext = exchangeContext; + Exchange = exchange; + Connection = connection; + _filterScope = filterScope; + } + + public ExchangeContext ExchangeContext { get; } + + public Exchange? Exchange { get; } + + public Connection? Connection { get; } + + /// <summary> + /// Evaluates a built-in variable name (e.g. <c>authority.host</c>, + /// <c>exchange.url</c>). Returns <c>false</c> for unknown names — callers + /// should then consult <see cref="VariableContext"/> for user-set and + /// environment variables. + /// </summary> + /// <remarks> + /// Allocation-free on the hot path. This replaced an older + /// <c>IDictionary<string, Func<string>></c> surface that + /// allocated ~1 KB per rule evaluation (dictionary + nine closures) and + /// accounted for 6–12% of bytes in the throughput benchmark. + /// </remarks> + public bool TryEvaluate(string name, out string value) + { + switch (name) { + case "authority.host": + value = ExchangeContext.Authority.HostName; + return true; + case "authority.port": + value = ExchangeContext.Authority.Port.ToString(); + return true; + case "authority.secure": + value = ExchangeContext.Authority.Secure.ToString(); + return true; + case "global.filterScope": + value = _filterScope.ToString(); + return true; + case "exchange.id": + value = Exchange?.Id.ToString() ?? string.Empty; + return true; + case "exchange.url": + value = Exchange?.FullUrl ?? string.Empty; + return true; + case "exchange.method": + value = Exchange?.Request.Header.Method.ToString() ?? string.Empty; + return true; + case "exchange.path": + value = Exchange?.Request.Header.Path.ToString() ?? string.Empty; + return true; + case "exchange.status": + value = Exchange?.StatusCode > 0 ? Exchange.StatusCode.ToString() : string.Empty; + return true; + default: + value = string.Empty; + return false; + } + } + } +} diff --git a/src/Fluxzy.Core/Rules/VariableContext.cs b/src/Fluxzy.Core/Rules/VariableContext.cs index d9c9cdcb..f67f11f7 100644 --- a/src/Fluxzy.Core/Rules/VariableContext.cs +++ b/src/Fluxzy.Core/Rules/VariableContext.cs @@ -68,15 +68,15 @@ public string EvaluateVariable( string str, VariableBuildingContext? buildingParam) { - // TODO : implement without regex + // TODO : implement without regex // TODO : add an escape character for the variable syntax return RegexVariable.Replace(str, match => { var variableName = match.Groups["variableName"].Value; if (buildingParam != null - && buildingParam.LazyVariableEvaluations.TryGetValue(variableName, out var func)) - return func(); + && buildingParam.TryEvaluate(variableName, out var builtIn)) + return builtIn; if (TryGet(variableName, out var value)) return value!; diff --git a/test/Fluxzy.Tests/Cli/Variables.cs b/test/Fluxzy.Tests/Cli/Variables.cs index ca2a90aa..9163f577 100644 --- a/test/Fluxzy.Tests/Cli/Variables.cs +++ b/test/Fluxzy.Tests/Cli/Variables.cs @@ -169,6 +169,7 @@ public async Task Request_Header_Extract_A_Value_Comment() [InlineData("authority.secure", "Secure")] [InlineData("exchange.url", "FullUrl")] [InlineData("exchange.method", "Method")] + [InlineData("exchange.path", "Path")] [InlineData("exchange.status", "StatusCode")] [InlineData("exchange.id", "Id")] public async Task Self_Generated_Context_Variables(string variableName, string propertyName) diff --git a/test/Fluxzy.Tests/UnitTests/Variables/VariableBuildingContextTests.cs b/test/Fluxzy.Tests/UnitTests/Variables/VariableBuildingContextTests.cs new file mode 100644 index 00000000..93e8fb03 --- /dev/null +++ b/test/Fluxzy.Tests/UnitTests/Variables/VariableBuildingContextTests.cs @@ -0,0 +1,181 @@ +// Copyright 2021 - Haga Rakotoharivelo - https://github.com/haga-rak + +using System; +using Fluxzy.Clients; +using Fluxzy.Core; +using Fluxzy.Rules; +using Xunit; + +namespace Fluxzy.Tests.UnitTests.Variables +{ + /// <summary> + /// Unit-level coverage for <see cref="VariableBuildingContext.TryEvaluate"/>. + /// The old implementation exposed the built-ins through an + /// <c>IDictionary<string, Func<string>></c> populated in the constructor; + /// these tests lock down the exact value each of the nine built-in names returns + /// so the new allocation-free switch must match the prior delegate bodies + /// byte-for-byte (including the null-exchange fallback semantics). + /// </summary> + public class VariableBuildingContextTests + { + private static VariableBuildingContext CreateContext( + out Exchange exchange, + string host = "fluxzy.test", + int port = 8443, + bool secure = true, + string requestLine = "POST /some/path?x=1 HTTP/1.1", + FilterScope filterScope = FilterScope.RequestHeaderReceivedFromClient) + { + var authority = new Authority(host, port, secure); + var setting = FluxzySetting.CreateLocalRandomPort(); + var variableContext = new VariableContext(); + var exchangeContext = new ExchangeContext(authority, variableContext, setting, null!); + + var requestHeader = (requestLine + $"\r\nHost: {host}\r\n\r\n").AsMemory(); + exchange = new Exchange(IIdProvider.FromZero, authority, requestHeader, "HTTP/1.1", DateTime.UtcNow); + + return new VariableBuildingContext(exchangeContext, exchange, connection: null, filterScope); + } + + [Fact] + public void TryEvaluate_AuthorityHost_ReturnsHostName() + { + var ctx = CreateContext(out _, host: "fluxzy.test"); + + Assert.True(ctx.TryEvaluate("authority.host", out var value)); + Assert.Equal("fluxzy.test", value); + } + + [Fact] + public void TryEvaluate_AuthorityPort_ReturnsPortAsString() + { + var ctx = CreateContext(out _, port: 8443); + + Assert.True(ctx.TryEvaluate("authority.port", out var value)); + Assert.Equal("8443", value); + } + + [Theory] + [InlineData(true, "True")] + [InlineData(false, "False")] + public void TryEvaluate_AuthoritySecure_ReturnsBoolAsString(bool secure, string expected) + { + var ctx = CreateContext(out _, secure: secure); + + Assert.True(ctx.TryEvaluate("authority.secure", out var value)); + Assert.Equal(expected, value); + } + + [Fact] + public void TryEvaluate_GlobalFilterScope_ReturnsEnumMemberName() + { + var ctx = CreateContext(out _, filterScope: FilterScope.ResponseHeaderReceivedFromRemote); + + Assert.True(ctx.TryEvaluate("global.filterScope", out var value)); + Assert.Equal(nameof(FilterScope.ResponseHeaderReceivedFromRemote), value); + } + + [Fact] + public void TryEvaluate_ExchangeId_ReturnsIdAsString() + { + var ctx = CreateContext(out var exchange); + + Assert.True(ctx.TryEvaluate("exchange.id", out var value)); + Assert.Equal(exchange.Id.ToString(), value); + } + + [Fact] + public void TryEvaluate_ExchangeUrl_ReturnsFullUrl() + { + var ctx = CreateContext(out var exchange); + + Assert.True(ctx.TryEvaluate("exchange.url", out var value)); + Assert.Equal(exchange.FullUrl, value); + } + + [Fact] + public void TryEvaluate_ExchangeMethod_ReturnsRequestMethod() + { + var ctx = CreateContext(out _, requestLine: "POST /x HTTP/1.1"); + + Assert.True(ctx.TryEvaluate("exchange.method", out var value)); + Assert.Equal("POST", value); + } + + [Fact] + public void TryEvaluate_ExchangePath_ReturnsRequestPath() + { + var ctx = CreateContext(out _, requestLine: "GET /some/path?x=1 HTTP/1.1"); + + Assert.True(ctx.TryEvaluate("exchange.path", out var value)); + Assert.Equal("/some/path?x=1", value); + } + + /// <summary> + /// Before the response arrives, <c>StatusCode</c> is 0 — the pre-refactor lambda + /// returned <see cref="string.Empty"/> (not <c>"0"</c>) for this case, and the + /// switch must preserve that. + /// </summary> + [Fact] + public void TryEvaluate_ExchangeStatus_ReturnsEmptyWhenStatusZero() + { + var ctx = CreateContext(out var exchange); + Assert.Equal(0, exchange.StatusCode); + + Assert.True(ctx.TryEvaluate("exchange.status", out var value)); + Assert.Equal(string.Empty, value); + } + + [Fact] + public void TryEvaluate_UnknownName_ReturnsFalseAndEmpty() + { + var ctx = CreateContext(out _); + + Assert.False(ctx.TryEvaluate("no.such.thing", out var value)); + Assert.Equal(string.Empty, value); + } + + /// <summary> + /// With a null exchange (e.g. pre-request rule evaluation phases), the + /// exchange-scoped variables must still return <see cref="string.Empty"/> + /// without throwing — matching the <c>exchange?.X ?? string.Empty</c> + /// pattern from the pre-refactor lambdas. + /// </summary> + [Theory] + [InlineData("exchange.id")] + [InlineData("exchange.url")] + [InlineData("exchange.method")] + [InlineData("exchange.path")] + [InlineData("exchange.status")] + public void TryEvaluate_ExchangeNull_ReturnsEmpty(string variableName) + { + var authority = new Authority("fluxzy.test", 443, true); + var setting = FluxzySetting.CreateLocalRandomPort(); + var variableContext = new VariableContext(); + var exchangeContext = new ExchangeContext(authority, variableContext, setting, null!); + + var ctx = new VariableBuildingContext( + exchangeContext, exchange: null, connection: null, + FilterScope.OnAuthorityReceived); + + Assert.True(ctx.TryEvaluate(variableName, out var value)); + Assert.Equal(string.Empty, value); + } + + /// <summary> + /// End-to-end: VariableContext.EvaluateVariable must dispatch through + /// <see cref="VariableBuildingContext.TryEvaluate"/> so <c>${authority.host}</c> + /// substitutions in rules still resolve. + /// </summary> + [Fact] + public void EvaluateVariable_InterpolatesBuiltInViaBuildingContext() + { + var ctx = CreateContext(out _, host: "fluxzy.test", port: 8443); + var holder = new VariableContext(); + + var result = holder.EvaluateVariable("host=${authority.host}:${authority.port}", ctx); + + Assert.Equal("host=fluxzy.test:8443", result); + } + } +}