Skip to content

Commit 9d57510

Browse files
authored
fix: resolve client CPU spin-loop + body pump / pooling / benchmark overhaul (#63)
2 parents 0bfff90 + 19c201d commit 9d57510

344 files changed

Lines changed: 8802 additions & 5608 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/commitlint.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ jobs:
2626
docs
2727
chore
2828
refactor
29+
style
2930
test
3031
ci
3132
build

.github/workflows/docs.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ jobs:
3030
- name: Setup Node.js
3131
uses: actions/setup-node@v6
3232
with:
33-
node-version: "22"
33+
node-version: "24"
3434
cache: "npm"
3535
cache-dependency-path: docs/package-lock.json
3636

.github/workflows/release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ jobs:
135135
- name: Setup Node.js
136136
uses: actions/setup-node@v6
137137
with:
138-
node-version: "22"
138+
node-version: "24"
139139
cache: "npm"
140140
cache-dependency-path: docs/package-lock.json
141141

CLAUDE.md

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ dotnet run --project GaudiHTTP.Tests/GaudiHTTP.Tests.csproj -- -class "GaudiHTTP
2828
$env:GAUDIHTTP_TEST_BACKEND = "kestrel" # force Kestrel (no Docker needed)
2929
$env:GAUDIHTTP_TEST_BACKEND = "docker" # force Docker (fails if unavailable)
3030

31-
# Benchmarks
32-
dotnet run --configuration Release --project GaudiHTTP.Benchmarks/GaudiHTTP.Benchmarks.csproj
31+
# Benchmarks (BenchmarkDotNet; filter by class) — see the "## Benchmarks" section below
32+
dotnet run --configuration Release --project GaudiHTTP.Benchmarks/GaudiHTTP.Benchmarks.csproj -- --filter '*PoolBenchmarks*'
3333

3434
# Docs site (Node.js 20+)
3535
cd ../docs && npm install && npm run docs:dev
@@ -47,6 +47,68 @@ Features (GaudiHTTP/Features/) - Cookies/, Caching/, AltSvc/
4747
Diagnostics (GaudiHTTP/Diagnostics/) - Metrics, tracing, logging
4848
```
4949

50+
## Benchmarks
51+
52+
`GaudiHTTP.Benchmarks` is a plain BenchmarkDotNet host (no custom report layer). Run from `src/`;
53+
select a class with `--filter`. Folders are scenario-based; each benchmark class is bound to exactly
54+
**one** of three per-type configs in `Internal/Config.cs` (via `[Config(...)]`, inherited through
55+
`BenchmarkSuiteBase` for the throughput suites).
56+
57+
```
58+
Client/ColdStart Latency Throughput Download Caching Streaming Allocation
59+
Server/Throughput Upload Micro
60+
```
61+
62+
```bash
63+
# Throughput / latency (in-process server ok) — EngineBenchmarkConfig
64+
dotnet run -c Release --project GaudiHTTP.Benchmarks/GaudiHTTP.Benchmarks.csproj -- --filter '*GaudiServerPlaintextBenchmark*'
65+
# Client allocation (out-of-process server) — AllocationBenchmarkConfig
66+
dotnet run -c Release --project GaudiHTTP.Benchmarks/GaudiHTTP.Benchmarks.csproj -- --filter '*ClientAllocationBenchmarks*'
67+
# CPU/pool micro — MicroBenchmarkConfig
68+
dotnet run -c Release --project GaudiHTTP.Benchmarks/GaudiHTTP.Benchmarks.csproj -- --filter '*PoolBenchmarks*'
69+
```
70+
71+
**Advantage scenarios (GaudiHttp vs out-of-the-box HttpClient):** `Client/Caching` (cache-hit
72+
goodput — HttpClient has no built-in cache), `Client/Streaming` (sustained channel-API pipeline +
73+
backpressure), `Client/Throughput/*HeavyConcurrent*` (heavy concurrent MB transfers). Each is a paired
74+
`*GaudiClient*` / `*HttpClient*` class with identical params. The memory/bounded-allocation story is the
75+
out-of-process `Client/Allocation` pair (GaudiClient vs HttpClient, each measured alone) — compared
76+
offline, fed from the EventPipe total only.
77+
78+
| Config | Used by | Job | Native exporters | Metric to read |
79+
|--------|---------|-----|------------------|----------------|
80+
| `EngineBenchmarkConfig` | Client/Server throughput, latency, coldstart, download | `Job.Default` + `MemoryDiagnoser` + `Req/sec` + P50/P95/P100 | JSON-full + CSV + GitHub-md (defaults) | **Mean / Req-per-sec / latency percentiles** |
81+
| `AllocationBenchmarkConfig` | `Client/Allocation` (out-of-process server) | Monitoring, low fixed iterations, `EventPipeProfiler(GcVerbose)` | + `AllocationByTypeExporter` | **process-wide alloc total** (`*.alloc-by-type.json`) |
82+
| `MicroBenchmarkConfig` | `Micro` (pool CPU stress) | Monitoring, low fixed iterations, `EventPipeProfiler(GcVerbose)` | + `AllocationByTypeExporter` | **process-wide alloc total** (`*.alloc-by-type.json`) |
83+
84+
Artifacts land in a per-run timestamped folder `src/BenchmarkDotNet.Artifacts/{yyyyMMdd_HHmmss}/`
85+
(gitignored). All three configs share one timestamp captured once at process start
86+
(`BenchmarkArtifacts.Path`), so a run spanning multiple configs resolves to a single root — distinct
87+
per-config paths are what triggers the `GetRootArtifactsFolderPath` crash. Charts (point at the run
88+
folder you want):
89+
90+
```bash
91+
cd docs && npm install # once, pulls chart.js for offline inlining
92+
npm run charts -- ../src/GaudiHTTP.Benchmarks/BenchmarkDotNet.Artifacts/20260630_120000 # -> .../charts.html
93+
```
94+
95+
`charts.html` is a single self-contained file (Chart.js inlined, no server): throughput, latency
96+
percentiles, and allocation. **Allocation bars are fed only from the EventPipe total in
97+
`*.alloc-by-type.json` — never the MemoryDiagnoser "Allocated" column.**
98+
99+
Four measurement constraints (non-obvious, baked into the configs — do not regress them):
100+
1. **MemoryDiagnoser only measures the calling thread** (`GetAllocatedBytesForCurrentThread`), so its
101+
"Allocated" column is structurally wrong for the Akka client / Task workloads (≈46 KB vs real
102+
~3.5 MB). Measure allocation **only** process-wide via EventPipe + `AllocationByTypeExporter`.
103+
2. **Client allocation needs the server out-of-process** (`ServerProcessHandle`, internal
104+
`--bench-server` entry in `Program.cs`). In-process Kestrel contaminates the process-wide total
105+
and throttles the client (shared thread pool) → ~3.7× under-count.
106+
3. **CPU- or network-heavy benches must use `RunStrategy.Monitoring` + fixed low iteration counts +
107+
`EventPipeProfiler(performExtraBenchmarksRun: false)`**, else the default Throughput strategy
108+
auto-scales to thousands of invocations and pins every core for minutes.
109+
4. **Throughput comparisons may stay in-process** (combined allocation acceptable there); the metric
110+
is Mean / Req-per-sec, not allocation.
111+
50112
## Debugging with Senf.Tracing
51113

52114
All state machines and stage logic are permanently instrumented with `Servus.Senf.Tracing`. To debug issues, activate tracing in tests — no ad-hoc `Console.Error.WriteLine` needed.

commitlint.config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export default {
1111
"docs",
1212
"chore",
1313
"refactor",
14+
"style",
1415
"test",
1516
"ci",
1617
"build",

docs/api/client-options.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
# TurboClientOptions
1+
# GaudiClientOptions
22

33
```csharp
4-
public sealed class TurboClientOptions
4+
public sealed class GaudiClientOptions
55
{
66
// Base address
77
public Uri? BaseAddress { get; set; }

docs/api/feature-options.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Feature Options and Builders
22

3-
Feature options configure optional features and are applied via the builder API, not through `TurboClientOptions`. All `With*` methods accept an optional configuration delegate; calling them without arguments enables the feature with its defaults. See [Configuration guide](/client/configuration) for builder usage.
3+
Feature options configure optional features and are applied via the builder API, not through `GaudiClientOptions`. All `With*` methods accept an optional configuration delegate; calling them without arguments enables the feature with its defaults. See [Configuration guide](/client/configuration) for builder usage.
44

55
## RetryOptions
66

@@ -191,7 +191,7 @@ public static class GaudiHttpClientBuilderExtensions
191191

192192
IGaudiHttpClientBuilder WithExpectContinue(this IGaudiHttpClientBuilder builder, Action<Expect100Options>? configure = null);
193193

194-
IGaudiHttpClientBuilder AddHandler<T>(this IGaudiHttpClientBuilder builder) where T : TurboHandler;
194+
IGaudiHttpClientBuilder AddHandler<T>(this IGaudiHttpClientBuilder builder) where T : GaudiHandler;
195195

196196
IGaudiHttpClientBuilder UseRequest(this IGaudiHttpClientBuilder builder, Func<HttpRequestMessage, HttpRequestMessage> transform);
197197

@@ -203,10 +203,10 @@ public static class GaudiHttpClientBuilderExtensions
203203

204204
## Custom Handlers
205205

206-
The `TurboHandler` base class provides a hook for custom request/response middleware:
206+
The `GaudiHandler` base class provides a hook for custom request/response middleware:
207207

208208
```csharp
209-
public abstract class TurboHandler
209+
public abstract class GaudiHandler
210210
{
211211
public virtual HttpRequestMessage ProcessRequest(HttpRequestMessage request) => request;
212212
public virtual HttpResponseMessage ProcessResponse(HttpRequestMessage original, HttpResponseMessage response) => response;
@@ -217,7 +217,7 @@ Custom handlers are registered via the builder:
217217

218218
```csharp
219219
// Define a custom handler
220-
public class AuthHeaderHandler : TurboHandler
220+
public class AuthHeaderHandler : GaudiHandler
221221
{
222222
public override HttpRequestMessage ProcessRequest(HttpRequestMessage request)
223223
{
@@ -250,6 +250,6 @@ These types are part of the public API and can be customized:
250250
|------|---------|-------|
251251
| `ICookieStore` | Cookie storage and injection — implement and pass to `.WithCookies(store)` | [Cookies](/client/cookies) |
252252
| `ICacheStore` | Cache backend (extends `IDisposable`) — implement and pass to `.WithCache(store)` | [Caching](/client/caching) |
253-
| `TurboHandler` | Custom request/response middleware — register via `.AddHandler<T>()` | [Configuration](/client/configuration) |
253+
| `GaudiHandler` | Custom request/response middleware — register via `.AddHandler<T>()` | [Configuration](/client/configuration) |
254254

255255
See [Configuration guide](/client/configuration) for integration patterns.

docs/api/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ GaudiHTTP's public API is organized into client, server, and feature configurati
88
|------|-------------|-----------|
99
| `IGaudiHttpClientFactory` | Creates named client instances | [Client API](./client) |
1010
| `IGaudiHttpClient` | The HTTP client — `SendAsync` and channel-based API | [Client API](./client) |
11-
| `TurboClientOptions` | Connection, TLS, proxy, and protocol settings | [Client Options](./client-options) |
11+
| `GaudiClientOptions` | Connection, TLS, proxy, and protocol settings | [Client Options](./client-options) |
1212
| `Http1ClientOptions` / `Http2ClientOptions` / `Http3ClientOptions` | Per-protocol tuning | [Client Options](./client-options) |
1313
| `RetryOptions` / `CacheOptions` / `RedirectOptions` | Feature configuration | [Feature Options](./feature-options) |
1414
| Builder extensions (`.WithRetry()`, `.WithCache()`, etc.) | Fluent feature composition | [Feature Options](./feature-options) |

docs/api/server.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -70,19 +70,19 @@ public sealed class GaudiServerOptions
7070
Http3ServerOptions Http3 { get; }
7171

7272
void Listen(IPAddress address, ushort port);
73-
void Listen(IPAddress address, ushort port, Action<TurboListenOptions> configure);
73+
void Listen(IPAddress address, ushort port, Action<GaudiListenOptions> configure);
7474
void Listen(string url);
75-
void Listen(string url, Action<TurboListenOptions> configure);
75+
void Listen(string url, Action<GaudiListenOptions> configure);
7676
void ListenLocalhost(ushort port);
77-
void ListenLocalhost(ushort port, Action<TurboListenOptions> configure);
77+
void ListenLocalhost(ushort port, Action<GaudiListenOptions> configure);
7878
void ListenAnyIP(ushort port);
79-
void ListenAnyIP(ushort port, Action<TurboListenOptions> configure);
79+
void ListenAnyIP(ushort port, Action<GaudiListenOptions> configure);
8080
void BindTcp(string host, ushort port);
8181
void Bind(TcpListenerOptions options);
8282
void Bind(QuicListenerOptions options);
8383
void Bind(ListenerOptions options, IListenerFactory factory);
8484
void ConfigureHttpsDefaults(Action<GaudiHttpsOptions> configure);
85-
void ConfigureEndpointDefaults(Action<TurboListenOptions> configure);
85+
void ConfigureEndpointDefaults(Action<GaudiListenOptions> configure);
8686

8787
IList<ListenerBinding> Endpoints { get; } // read-only, populated by Bind() overloads only
8888
IList<string> Urls { get; } // mutable list — add URL strings manually or via hosting configuration; resolved to bindings at startup
@@ -117,7 +117,7 @@ public sealed class GaudiServerLimits
117117
## Listen Options
118118

119119
```csharp
120-
public sealed class TurboListenOptions(IPAddress address, ushort port)
120+
public sealed class GaudiListenOptions(IPAddress address, ushort port)
121121
{
122122
IPAddress Address { get; }
123123
ushort Port { get; }
@@ -139,7 +139,7 @@ public sealed class TurboListenOptions(IPAddress address, ushort port)
139139

140140
## Transport Buffer Options
141141

142-
Controls backpressure thresholds on the read/write pipes between the OS socket and the HTTP pipeline. Applied per-connection for TCP and per-stream for QUIC. Set via `TurboListenOptions.Transport`. Every property is nullable — properties left at `null` fall back to the protocol-optimized default individually, so you only need to set the thresholds you want to change. A resume threshold above its pause threshold fails endpoint resolution with `InvalidOperationException`.
142+
Controls backpressure thresholds on the read/write pipes between the OS socket and the HTTP pipeline. Applied per-connection for TCP and per-stream for QUIC. Set via `GaudiListenOptions.Transport`. Every property is nullable — properties left at `null` fall back to the protocol-optimized default individually, so you only need to set the thresholds you want to change. A resume threshold above its pause threshold fails endpoint resolution with `InvalidOperationException`.
143143

144144
```csharp
145145
public sealed class TransportBufferOptions

docs/architecture/handlers.md

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ services.AddGaudiHttpClient<IGitHubClient, GitHubClient>(options =>
5454

5555
The return value is `IGaudiHttpClientBuilder` — all further options are registered as extension methods on it. The graph is not materialized here, but on the first `CreateClient(name)` call of the factory.
5656

57-
HTTP version and default headers are set on the `IGaudiHttpClient` instance, not on `TurboClientOptions`:
57+
HTTP version and default headers are set on the `IGaudiHttpClient` instance, not on `GaudiClientOptions`:
5858

5959
```csharp
6060
var client = factory.CreateClient("myapi");
@@ -88,7 +88,7 @@ These methods only register their configuration in `IServiceCollection` (as `IOp
8888

8989
> **Note:** Feature configuration (redirect, retry, cache, cookies) is done exclusively through the
9090
> builder extensions (`.WithRedirect()`, `.WithRetry()`, `.WithCache()`, `.WithCookies()`).
91-
> These are not properties on `TurboClientOptions`.
91+
> These are not properties on `GaudiClientOptions`.
9292
9393
---
9494

@@ -97,7 +97,7 @@ These methods only register their configuration in `IServiceCollection` (as `IOp
9797
Instead of `DelegatingHandler`, GaudiHTTP provides its own stream-compatible middleware abstraction. The interface is intentionally simple — no Akka knowledge required:
9898

9999
```csharp
100-
public abstract class TurboHandler
100+
public abstract class GaudiHandler
101101
{
102102
// Optional: request transform — default is pass-through
103103
public virtual HttpRequestMessage ProcessRequest(
@@ -212,7 +212,7 @@ public class PaymentService(IGaudiHttpClientFactory factory)
212212

213213
```csharp
214214
// Custom handler
215-
public sealed class AuthHandler(ITokenProvider tokens) : TurboHandler
215+
public sealed class AuthHandler(ITokenProvider tokens) : GaudiHandler
216216
{
217217
public override HttpRequestMessage ProcessRequest(HttpRequestMessage request)
218218
{
@@ -224,28 +224,28 @@ public sealed class AuthHandler(ITokenProvider tokens) : TurboHandler
224224

225225
---
226226

227-
## Difference from `TurboClientOptions`
227+
## Difference from `GaudiClientOptions`
228228

229-
`TurboClientOptions` remains as **transport configuration** (timeouts, TLS, reconnect intervals). Handler configuration (cookies, cache, retry, redirect, user handlers) moves entirely into the `IGaudiHttpClientBuilder` extensions.
229+
`GaudiClientOptions` remains as **transport configuration** (timeouts, TLS, reconnect intervals). Handler configuration (cookies, cache, retry, redirect, user handlers) moves entirely into the `IGaudiHttpClientBuilder` extensions.
230230

231231
| Configuration Type | Where |
232232
| -------------------------------------------------------- | ------------------------------------------------------------------- |
233-
| Connection parameters (timeouts, TLS, HTTP/2 frame size) | `TurboClientOptions` via `AddGaudiHttpClient(name, options => ...)` |
233+
| Connection parameters (timeouts, TLS, HTTP/2 frame size) | `GaudiClientOptions` via `AddGaudiHttpClient(name, options => ...)` |
234234
| Redirect / Retry / Cookie / Cache | `IGaudiHttpClientBuilder` extensions (`.WithRedirect()` etc.) |
235235
| User handlers | `IGaudiHttpClientBuilder` (`.AddHandler<T>()`) |
236-
| BaseAddress | `TurboClientOptions` |
236+
| BaseAddress | `GaudiClientOptions` |
237237
| DefaultRequestHeaders / DefaultRequestVersion | `IGaudiHttpClient` (set on the client instance after creation) |
238238

239239
---
240240

241241
## How Configuration Becomes a Pipeline
242242

243-
### `TurboClientDescriptor`
243+
### `GaudiClientDescriptor`
244244

245245
Collects all the settings you register via the builder extensions (`.WithRedirect()`, `.AddHandler<T>()`, etc.):
246246

247247
```csharp
248-
internal sealed class TurboClientDescriptor
248+
internal sealed class GaudiClientDescriptor
249249
{
250250
public RedirectPolicy? RedirectPolicy { get; set; }
251251
public RetryPolicy? RetryPolicy { get; set; }
@@ -263,7 +263,7 @@ internal sealed class TurboClientDescriptor
263263
// Unified FIFO factory list: covers both type-based (AddHandler<T>) AND
264264
// delegate-based (UseRequest/UseResponse) handlers.
265265
// AddHandler<T> registers into BOTH lists; UseRequest/UseResponse only here.
266-
public List<Func<IServiceProvider, TurboHandler>> HandlerFactories { get; } = [];
266+
public List<Func<IServiceProvider, GaudiHandler>> HandlerFactories { get; } = [];
267267
}
268268
```
269269

@@ -280,7 +280,7 @@ internal sealed record PipelineDescriptor(
280280
CookieJar? CookieJar,
281281
Cache? CacheStore,
282282
CachePolicy? CachePolicy,
283-
IReadOnlyList<TurboHandler> Handlers,
283+
IReadOnlyList<GaudiHandler> Handlers,
284284
bool AutomaticDecompression = true,
285285
AltSvcCache? AltSvcCache = null,
286286
bool UseProxy = true,
@@ -314,7 +314,7 @@ The engine reads this descriptor and wires up only the stages you have actually
314314
| Retry | off — Polly via `.AddStandardResilienceHandler()` | off — opt-in via `.WithRetry(policy)` |
315315
| Cache | not available | off — opt-in via `.WithCache(policy)` |
316316
| Cookies | off (SocketsHttpHandler) | off — opt-in via `.WithCookies()` |
317-
| Handler base | `DelegatingHandler` (sync/async, per request) | `TurboHandler` (async, stream-compatible) |
317+
| Handler base | `DelegatingHandler` (sync/async, per request) | `GaudiHandler` (async, stream-compatible) |
318318
| Factory | `IHttpClientFactory` | `IGaudiHttpClientFactory` |
319319
| Typed Clients | `AddHttpClient<TClient>()` | `AddGaudiHttpClient<TClient>()` |
320320

0 commit comments

Comments
 (0)