Skip to content

Commit 29e4f0e

Browse files
committed
chore: new HttpProtocolException
1 parent e90ede1 commit 29e4f0e

151 files changed

Lines changed: 2131 additions & 1877 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.

docs/superpowers/plans/2026-05-13-h11-decoder-pipe-migration.md

Lines changed: 1129 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
# H/1.1 Decoder Pipe Migration Design
2+
3+
**Date:** 2026-05-13
4+
**Scope:** `TurboHTTP.Protocol.Http11``Decoder`, `ServerRequestDecoder`, `ServerStateMachine`, `StateMachine`
5+
**Status:** Approved
6+
7+
## Goal
8+
9+
Remove the `DecoderTestExtensions` shim and `ServerRequestDecoder.TryDecode`'s synchronous `ByteArrayContent` accumulation. Move the `PipedStreamContent`/pipe lifecycle into the decoders themselves so no caller ever constructs a `Pipe` or `PipedStreamContent` externally. Tests and StateMachines use a single `TryDecode` entry point.
10+
11+
## Background
12+
13+
### Current state (legacy)
14+
15+
- `DecoderTestExtensions.TryDecode` — extension method on `Decoder` that wraps `TryDecodeHeaders` + `FeedBody` into a synchronous "collect all responses" API. Uses a static `Dictionary<int, PendingResponse>` keyed by `RuntimeHelpers.GetHashCode` (memory leak risk). ~50 test files depend on it.
16+
- `ServerRequestDecoder.TryDecode` — real method on the decoder that synchronously accumulates body bytes into `ByteArrayContent` before returning. Does not match production `StateMachine` behavior.
17+
- Both `ServerStateMachine` and `StateMachine` (H/1.1) create `RequestBodyHandle`/`ResponseBodyHandle` externally, then attach `new PipedStreamContent(handle.Reader)` to the request/response.
18+
19+
### Target state
20+
21+
All pipe lifecycle is owned by the decoder. StateMachines orchestrate only; decoders produce ready-to-consume messages with `PipedStreamContent` already attached.
22+
23+
## API Shape
24+
25+
### `ServerRequestDecoder`
26+
27+
**Single public entry point:**
28+
29+
```csharp
30+
public bool TryDecode(ReadOnlyMemory<byte> data, out HttpRequestMessage? request)
31+
```
32+
33+
Behaviour:
34+
- Returns `true` + populated `request` when headers are complete (once per inbound request)
35+
- `request.Content` is set to `PipedStreamContent` when a body is expected; `null` when no body
36+
- Subsequent calls with body data return `false` — body bytes are written into the internal pipe
37+
- When body completes, pipe writer is closed; `request.Content.ReadAsStringAsync()` resolves
38+
- After body completion the decoder immediately tries the next header block from the remainder (pipelining)
39+
40+
**Convenience overload for tests and pipelining loops:**
41+
42+
```csharp
43+
public bool TryDecode(ReadOnlyMemory<byte> data, out IReadOnlyList<HttpRequestMessage> requests)
44+
```
45+
46+
Wraps the single-request overload in a loop, collecting all complete requests from one data chunk. For pipelined data, after each request is returned, the loop continues with `ReadOnlyMemory<byte>.Empty` so the decoder drains its remainder buffer. Body data for each request is fed and the pipe completed before the decoder advances to the next request's headers.
47+
48+
**Abort surface:**
49+
50+
```csharp
51+
public void AbortBody(Exception reason)
52+
```
53+
54+
Called by `StateMachine` on timeout or connection close. Completes the internal pipe writer with the exception so any awaiting consumer receives a clean error.
55+
56+
**Removed from public surface:** `TryDecodeHeaders`, `FeedBody`, `Phase` property.
57+
58+
---
59+
60+
### `Decoder` (H/1.1 response, client-side)
61+
62+
Same treatment:
63+
64+
```csharp
65+
public bool TryDecode(ReadOnlyMemory<byte> data, out HttpResponseMessage? response)
66+
public bool TryDecode(ReadOnlyMemory<byte> data, out IReadOnlyList<HttpResponseMessage>? responses)
67+
public void AbortBody(Exception reason)
68+
```
69+
70+
`TryDecodeHead` and `TryDecodeConnect` become overloads or named methods on `Decoder` directly (not on `DecoderTestExtensions`).
71+
`TryDecodeEof` remains — it has distinct semantics (no `data` parameter, reads from internal remainder for close-delimited responses).
72+
`FeedBody`, `TryDecodeHeaders`, `Phase` removed from public surface.
73+
74+
## StateMachine Changes
75+
76+
### `ServerStateMachine`
77+
78+
**Removed:**
79+
- `RequestBodyHandle? _activeBody` field
80+
- `if (_decoder.Phase == DecoderPhase.Body && _activeBody is not null)` block
81+
- `new RequestBodyHandle(...)` construction
82+
- `new PipedStreamContent(handle.Reader)` assignment
83+
84+
**New `DecodeClientData`:**
85+
86+
```csharp
87+
public void DecodeClientData(ITransportInbound data)
88+
{
89+
if (data is not TransportData { Buffer: var buffer }) return;
90+
91+
while (_decoder.TryDecode(buffer.Memory, out var request))
92+
{
93+
if (_pendingResponseCount >= _maxPipelinedRequests)
94+
{
95+
ShouldComplete = true;
96+
buffer.Dispose();
97+
return;
98+
}
99+
100+
_ops.OnCancelTimer(RequestHeadersTimeoutKey);
101+
_ops.OnScheduleTimer(KeepAliveTimeoutKey, _keepAliveTimeout);
102+
CheckConnectionClose(request!);
103+
_pendingResponseCount++;
104+
_ops.OnRequest(request!);
105+
buffer = TransportBuffer.Empty;
106+
}
107+
108+
buffer.Dispose();
109+
}
110+
```
111+
112+
Timeout and connection-close paths call `_decoder.AbortBody(reason)` where they previously called `_activeBody.Abort(...)`.
113+
114+
### `StateMachine` (client-side)
115+
116+
Same simplification: `ResponseBodyHandle? _responseBody` removed; loop calls `_decoder.TryDecode(data, out var response)`.
117+
118+
### `RequestBodyHandle` / `ResponseBodyHandle`
119+
120+
Moved to `internal` — they become implementation details of the respective decoder. No external type references them after the refactor.
121+
122+
## Test Migration
123+
124+
### Tests using `ServerRequestDecoder.TryDecode(data, out IReadOnlyList<HttpRequestMessage>)`
125+
126+
The convenience overload preserves this signature. **No test changes required** for simple single-request tests.
127+
128+
The only observable difference: `request.Content` is now `PipedStreamContent` instead of `ByteArrayContent`. All existing tests already use `await request.Content.ReadAsStringAsync(...)` — this continues to work because the pipe is completed synchronously when all data arrives in one chunk.
129+
130+
### Tests using `DecoderTestExtensions.TryDecode`
131+
132+
Same: `Decoder` gains a `TryDecode(ReadOnlyMemory<byte>, out IReadOnlyList<HttpResponseMessage>?)` convenience overload. The ~50 test files remain **unchanged**.
133+
134+
Exceptions requiring manual updates:
135+
- Tests calling `TryDecodeHead` / `TryDecodeConnect` → equivalent methods move to `Decoder` directly
136+
- Tests asserting `decoder.Phase` directly → rewrite to assert behavioural outcomes (request returned or not)
137+
- Benchmark files `Http11DecoderBenchmark` and `Http11ChunkedDecoderBenchmark` → updated to call `TryDecode`
138+
139+
### `DecoderTestExtensions.cs`
140+
141+
**Deleted.** The static `_pendingResponses` dictionary and all shim logic disappears entirely.
142+
143+
## Deletion Candidates
144+
145+
| What | Location | Replacement |
146+
|---|---|---|
147+
| `DecoderTestExtensions.cs` | `Tests/Http11/` | Overloads on `Decoder` |
148+
| `Decoder.TryDecodeHeaders` (public) | `Protocol/Http11/Decoder.cs` | `TryDecode` |
149+
| `Decoder.FeedBody` (public) | `Protocol/Http11/Decoder.cs` | internal |
150+
| `Decoder.Phase` (public) | `Protocol/Http11/Decoder.cs` | internal |
151+
| `ServerRequestDecoder.TryDecodeHeaders` (public) | `Protocol/Http11/ServerRequestDecoder.cs` | `TryDecode` |
152+
| `ServerRequestDecoder.FeedBody` (public) | `Protocol/Http11/ServerRequestDecoder.cs` | internal |
153+
| `ServerRequestDecoder.Phase` (public) | `Protocol/Http11/ServerRequestDecoder.cs` | internal |
154+
| `RequestBodyHandle` as external type | `Protocol/Http11/` | internal in decoder |
155+
| `_activeBody` field in `ServerStateMachine` | `Protocol/Http11/ServerStateMachine.cs` | removed |
156+
157+
## Risks
158+
159+
**Pipelining sequencing:** The new `TryDecode` loop must feed all of request N's body before decoding request N+1's headers from the remainder. This sequencing currently lives in `StateMachine`; it must be replicated correctly inside the decoder. Covered by existing `Http11RoundTripPipeliningSpec` tests.
160+
161+
**`TryDecodeEof`:** Remains as a separate method. Close-delimited response handling is distinct — no data parameter, reads from internal remainder. Not affected by this refactor.
162+
163+
**`AbortBody` coverage:** Every timeout/disconnect path in `ServerStateMachine` that previously called `_activeBody.Abort(...)` must be updated to call `_decoder.AbortBody(reason)`. Requires careful audit of the `StateMachine` timeout paths.
164+
165+
**Benchmark breakage:** `Http11DecoderBenchmark` and `Http11ChunkedDecoderBenchmark` call `TryDecodeHeaders`/`FeedBody` directly. These must be updated as part of the implementation.
166+
167+
## Out of Scope (future work)
168+
169+
H/1.0, H/2, and H/3 `StateMachine` classes also build pipes externally and should receive the same treatment. Deferred until H/1.1 refactor is complete and patterns are proven.

src/Servus.Akka.TestKit/TestConnectionStageBuilder.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ public TestConnectionStageBuilder AutoConnect(ConnectionInfo? info = null)
1818

1919
public TestConnectionStageBuilder AutoDisconnect()
2020
{
21-
return OnOutbound<DisconnectTransport>((msg, ctx)
21+
return OnOutbound<DisconnectTransport>((msg, ctx)
2222
=> ctx.Push(new TransportDisconnected(msg.Reason)));
2323
}
2424

@@ -39,7 +39,7 @@ public TestConnectionStageBuilder WithActivityLog(ActivityLog log)
3939

4040
public TestConnectionStage Build()
4141
{
42-
var stage = new TestConnectionStage([.._handlers], _activityLog);
42+
var stage = new TestConnectionStage([.. _handlers], _activityLog);
4343

4444
if (_autoConnect)
4545
{

src/Servus.Akka.TestKit/TestPipeline.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ public static async Task<IReadOnlyList<TOut>> RunManyAsync<TIn, TOut>(
2929
IMaterializer materializer,
3030
TimeSpan? timeout = null,
3131
CancellationToken ct = default)
32-
{
32+
{
3333
var result = Source.From(inputs)
3434
.Via(flow)
3535
.Take(expectedCount)

src/Servus.Akka/Transport/Quic/Client/QuicConnectionStage.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,13 +99,13 @@ void ITransportOperations.OnSignalPullOutbound()
9999
}
100100
}
101101

102-
void ITransportOperations.OnCompleteStage()
102+
void ITransportOperations.OnCompleteStage()
103103
=> CompleteStage();
104104

105105
void ITransportOperations.OnScheduleTimer(string key, TimeSpan delay)
106106
=> ScheduleOnce(key, delay);
107107

108-
void ITransportOperations.OnCancelTimer(string key)
108+
void ITransportOperations.OnCancelTimer(string key)
109109
=> CancelTimer(key);
110110

111111
ILoggingAdapter ITransportOperations.Log => Log;

src/Servus.Akka/Transport/StreamTarget.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,6 @@ public readonly record struct StreamTarget(long Value)
77
public override string ToString() => Value.ToString();
88

99
public static implicit operator StreamTarget(long value) => new(value);
10-
public static implicit operator StreamTarget(int value) => new(value);
10+
public static implicit operator StreamTarget(int value) => new(value);
1111
public static implicit operator long(StreamTarget target) => target.Value;
1212
}

src/TurboHTTP.Tests/Caching/Stages/CacheBidiAsyncBodySpec.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,10 @@ protected override bool TryComputeLength(out long length)
3232

3333
protected override Task<Stream> CreateContentReadStreamAsync()
3434
{
35-
return _tcs.Task.ContinueWith(t =>
35+
return _tcs.Task.ContinueWith(Stream (t) =>
3636
{
3737
var ms = new MemoryStream(t.Result);
38-
return (Stream)ms;
38+
return ms;
3939
}, TaskScheduler.Default);
4040
}
4141
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
global using HttpProtocolException = TurboHTTP.Protocol.HttpProtocolException;

src/TurboHTTP.Tests/Http10/DecoderBodySpec.cs

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
using System.Net;
22
using System.Text;
3-
using TurboHTTP.Protocol;
43
using Decoder = TurboHTTP.Protocol.Http10.Decoder;
54

65
namespace TurboHTTP.Tests.Http10;
@@ -133,8 +132,7 @@ public void Http10DecoderBodySpec_should_throw_decoder_exception()
133132
var decoder = new Decoder();
134133
var data = BuildRawResponse("HTTP/1.0 200 OK", "Content-Length: -1");
135134

136-
var ex = Assert.Throws<HttpDecoderException>(() => decoder.TryDecode(data, out _));
137-
Assert.Equal(HttpDecoderError.InvalidContentLength, ex.DecodeError);
135+
var ex = Assert.Throws<HttpProtocolException>(() => decoder.TryDecode(data, out _));
138136
}
139137

140138
[Fact(Timeout = 5000)]
@@ -144,8 +142,7 @@ public void Http10DecoderBodySpec_should_throw_multiple_content_length()
144142
var decoder = new Decoder();
145143
const string raw = "HTTP/1.0 200 OK\r\nContent-Length: 3\r\nContent-Length: 5\r\n\r\nHello";
146144

147-
var ex = Assert.Throws<HttpDecoderException>(() => decoder.TryDecode(Bytes(raw), out _));
148-
Assert.Equal(HttpDecoderError.MultipleContentLengthValues, ex.DecodeError);
145+
var ex = Assert.Throws<HttpProtocolException>(() => decoder.TryDecode(Bytes(raw), out _));
149146
}
150147

151148
[Fact(Timeout = 5000)]

src/TurboHTTP.Tests/Http10/DecoderHeaderLimitsSpec.cs

Lines changed: 12 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
using System.Text;
2-
using TurboHTTP.Protocol;
32
using Decoder = TurboHTTP.Protocol.Http10.Decoder;
43

54
namespace TurboHTTP.Tests.Http10;
@@ -58,9 +57,8 @@ public void Http10DecoderHeaderLimitsSpec_should_throw_header_too_large()
5857
var bigValue = new string('X', 200);
5958
var raw = BuildRawResponse("HTTP/1.0 200 OK", $"X-Big: {bigValue}\r\nContent-Length: 0");
6059

61-
var ex = Assert.Throws<HttpDecoderException>(() => decoder.TryDecode(Bytes(raw), out _));
60+
var ex = Assert.Throws<HttpProtocolException>(() => decoder.TryDecode(Bytes(raw), out _));
6261

63-
Assert.Equal(HttpDecoderError.HeaderTooLarge, ex.DecodeError);
6462
Assert.Contains("X-Big", ex.Message);
6563
Assert.Contains("100", ex.Message);
6664
}
@@ -90,8 +88,7 @@ public void Http10DecoderHeaderLimitsSpec_should_throw_header_too_large_2()
9088
var decoder = new Decoder(maxHeaderSize: limit);
9189
var raw = BuildRawResponse("HTTP/1.0 200 OK", $"X: {value}\r\nContent-Length: 0");
9290

93-
var ex = Assert.Throws<HttpDecoderException>(() => decoder.TryDecode(Bytes(raw), out _));
94-
Assert.Equal(HttpDecoderError.HeaderTooLarge, ex.DecodeError);
91+
var ex = Assert.Throws<HttpProtocolException>(() => decoder.TryDecode(Bytes(raw), out _));
9592
}
9693

9794
[Fact(Timeout = 5000)]
@@ -123,9 +120,8 @@ public void Http10DecoderHeaderLimitsSpec_should_throw_total_headers_too_large()
123120
sb.Append("Content-Length: 0");
124121
var raw = BuildRawResponse("HTTP/1.0 200 OK", sb.ToString());
125122

126-
var ex = Assert.Throws<HttpDecoderException>(() => decoder.TryDecode(Bytes(raw), out _));
123+
var ex = Assert.Throws<HttpProtocolException>(() => decoder.TryDecode(Bytes(raw), out _));
127124

128-
Assert.Equal(HttpDecoderError.TotalHeadersTooLarge, ex.DecodeError);
129125
Assert.Contains("200", ex.Message);
130126
}
131127

@@ -151,8 +147,7 @@ public void Http10DecoderHeaderLimitsSpec_should_throw_total_headers_too_large_2
151147
var decoder = new Decoder(maxHeaderSize: 100, maxTotalHeaderSize: 8);
152148
var raw = BuildRawResponse("HTTP/1.0 200 OK", "X: V\r\nY: WW");
153149

154-
var ex = Assert.Throws<HttpDecoderException>(() => decoder.TryDecode(Bytes(raw), out _));
155-
Assert.Equal(HttpDecoderError.TotalHeadersTooLarge, ex.DecodeError);
150+
var ex = Assert.Throws<HttpProtocolException>(() => decoder.TryDecode(Bytes(raw), out _));
156151
}
157152

158153
[Fact(Timeout = 5000)]
@@ -163,8 +158,7 @@ public void Http10DecoderHeaderLimitsSpec_should_reject_at_custom_limit()
163158
var raw = BuildRawResponse("HTTP/1.0 200 OK",
164159
"X-TooLong: this-value-is-way-too-long-for-limit\r\nContent-Length: 0");
165160

166-
var ex = Assert.Throws<HttpDecoderException>(() => decoder.TryDecode(Bytes(raw), out _));
167-
Assert.Equal(HttpDecoderError.HeaderTooLarge, ex.DecodeError);
161+
var ex = Assert.Throws<HttpProtocolException>(() => decoder.TryDecode(Bytes(raw), out _));
168162
}
169163

170164
[Fact(Timeout = 5000)]
@@ -181,8 +175,7 @@ public void Http10DecoderHeaderLimitsSpec_should_reject_at_custom_total_limit()
181175
sb.Append("Content-Length: 0");
182176
var raw = BuildRawResponse("HTTP/1.0 200 OK", sb.ToString());
183177

184-
var ex = Assert.Throws<HttpDecoderException>(() => decoder.TryDecode(Bytes(raw), out _));
185-
Assert.Equal(HttpDecoderError.TotalHeadersTooLarge, ex.DecodeError);
178+
var ex = Assert.Throws<HttpProtocolException>(() => decoder.TryDecode(Bytes(raw), out _));
186179
}
187180

188181
[Fact(Timeout = 5000)]
@@ -194,8 +187,7 @@ public void Http10DecoderHeaderLimitsSpec_should_throw_header_too_large_3()
194187
const string raw =
195188
"HTTP/1.0 200 OK\r\nX-Folded: part1\r\n continued-text-that-is-long\r\nContent-Length: 0\r\n\r\n";
196189

197-
var ex = Assert.Throws<HttpDecoderException>(() => decoder.TryDecode(Bytes(raw), out _));
198-
Assert.Equal(HttpDecoderError.HeaderTooLarge, ex.DecodeError);
190+
var ex = Assert.Throws<HttpProtocolException>(() => decoder.TryDecode(Bytes(raw), out _));
199191
Assert.Contains("X-Folded", ex.Message);
200192
}
201193

@@ -208,10 +200,7 @@ public void Http10DecoderHeaderLimitsSpec_should_throw_total_headers_too_large_3
208200
const string raw =
209201
"HTTP/1.0 200 OK\r\nX-A: value-a\r\nX-Folded: part1\r\n continuation-that-pushes-total-over\r\nContent-Length: 0\r\n\r\n";
210202

211-
var ex = Assert.Throws<HttpDecoderException>(() => decoder.TryDecode(Bytes(raw), out _));
212-
Assert.True(
213-
ex.DecodeError is HttpDecoderError.TotalHeadersTooLarge or HttpDecoderError.HeaderTooLarge,
214-
$"Expected HeaderTooLarge or TotalHeadersTooLarge, got {ex.DecodeError}");
203+
Assert.Throws<HttpProtocolException>(() => decoder.TryDecode(Bytes(raw), out _));
215204
}
216205

217206
[Fact(Timeout = 5000)]
@@ -222,7 +211,7 @@ public void Http10DecoderHeaderLimitsSpec_should_include_header_name()
222211
var raw = BuildRawResponse("HTTP/1.0 200 OK",
223212
"X-Offending: this-value-exceeds-the-configured-limit\r\nContent-Length: 0");
224213

225-
var ex = Assert.Throws<HttpDecoderException>(() => decoder.TryDecode(Bytes(raw), out _));
214+
var ex = Assert.Throws<HttpProtocolException>(() => decoder.TryDecode(Bytes(raw), out _));
226215

227216
Assert.Contains("X-Offending", ex.Message);
228217
Assert.Contains("30", ex.Message);
@@ -236,7 +225,7 @@ public void Http10DecoderHeaderLimitsSpec_should_include_total_size()
236225
var raw = BuildRawResponse("HTTP/1.0 200 OK",
237226
"X-A: aaaaaaaaaa\r\nX-B: bbbbbbbbbb\r\nContent-Length: 0");
238227

239-
var ex = Assert.Throws<HttpDecoderException>(() => decoder.TryDecode(Bytes(raw), out _));
228+
var ex = Assert.Throws<HttpProtocolException>(() => decoder.TryDecode(Bytes(raw), out _));
240229

241230
Assert.Contains("30", ex.Message);
242231
}
@@ -252,9 +241,8 @@ public void Http10DecoderHeaderLimitsSpec_should_throw_header_too_large_4()
252241

253242
// Feed all data � TryDecode returns the response with body since headers end is found
254243
// But the header validation happens during header parsing in TryDecode
255-
var ex = Assert.Throws<HttpDecoderException>(() =>
244+
var ex = Assert.Throws<HttpProtocolException>(() =>
256245
decoder.TryDecode(Bytes(raw), out _));
257-
Assert.Equal(HttpDecoderError.HeaderTooLarge, ex.DecodeError);
258246
}
259247

260248
[Fact(Timeout = 5000)]
@@ -265,9 +253,8 @@ public void Http10DecoderHeaderLimitsSpec_should_throw_header_too_large_5()
265253
var bigValue = new string('C', 50);
266254
var raw = $"HTTP/1.0 200 OK\r\nX-Connect: {bigValue}\r\n\r\n";
267255

268-
var ex = Assert.Throws<HttpDecoderException>(() =>
256+
var ex = Assert.Throws<HttpProtocolException>(() =>
269257
decoder.TryDecodeConnect(Bytes(raw), out _));
270-
Assert.Equal(HttpDecoderError.HeaderTooLarge, ex.DecodeError);
271258
}
272259

273260
[Fact(Timeout = 5000)]

0 commit comments

Comments
 (0)