Skip to content

Commit f32fbef

Browse files
authored
refactor: complexity cleanup across protocol state machines, body pumps, and stages (+3 bug fixes) (#66)
2 parents fd0c806 + e1005bb commit f32fbef

40 files changed

Lines changed: 2090 additions & 1327 deletions

src/GaudiHTTP.Tests/Pooling/ConnectionObjectPoolSpec.cs

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -61,29 +61,29 @@ public void Dispose_on_a_Poolable_returns_it_to_the_singleton_pool_reset()
6161
ctx.Return(b);
6262
}
6363

64-
private sealed class NeverRentedCounter : Poolable<NeverRentedCounter>
64+
private sealed class ReturnFirstCounter : Poolable<ReturnFirstCounter>
6565
{
6666
public int Value;
67-
public int ResetCount;
68-
protected override void OnReset()
69-
{
70-
Value = 0;
71-
ResetCount++;
72-
}
67+
protected override void OnReset() => Value = 0;
7368
}
7469

7570
[Fact(Timeout = 5000)]
76-
public void Return_without_prior_rent_resets_and_discards_instead_of_throwing()
71+
public void Return_before_any_rent_must_not_throw_and_later_rent_reuses_the_instance()
7772
{
78-
// Directly-constructed poolables (tests injecting fakes, abort paths racing the first
79-
// Rent<T> of the process) dispose without a pool existing for their type. That must
80-
// behave like returning to a full pool — reset and drop — not throw.
81-
var a = new NeverRentedCounter { Value = 42 };
73+
// A pooled object constructed directly (new, not rented) disposes itself back into the
74+
// pool. That Return may be the very first pool touch for the type — it must not require
75+
// a factory registration.
76+
var ctx = ConnectionObjectPool.Instance;
77+
var a = new ReturnFirstCounter { Value = 5 };
8278

8379
a.Dispose();
8480

85-
Assert.Equal(1, a.ResetCount);
86-
Assert.Equal(0, a.Value);
81+
var b = ctx.Rent(static () => new ReturnFirstCounter());
82+
83+
Assert.Same(a, b);
84+
Assert.Equal(0, b.Value);
85+
86+
ctx.Return(b);
8787
}
8888

8989
[Fact(Timeout = 5000)]

src/GaudiHTTP.Tests/Protocol/Body/FlowControlledBodyPumpSpec.cs

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
using System.Collections;
2+
using System.Reflection;
13
using Akka.Actor;
24
using GaudiHTTP.Protocol.Body;
35
using GaudiHTTP.Protocol.Syntax.Http2;
@@ -89,9 +91,6 @@ private static void DrainToCompletion(FlowControlledBodyPump scheduler, FakeTarg
8991
case BodyReadComplete<int> rc:
9092
scheduler.HandleReadComplete(rc.StreamId, rc.BytesRead);
9193
break;
92-
case BodyReadContinue<int> dc:
93-
scheduler.HandleBodyReadContinue(dc.StreamId);
94-
break;
9594
}
9695
}
9796
}
@@ -369,6 +368,77 @@ public void OrphanRefund_should_return_reserved_window_when_stream_cancelled_dur
369368
Assert.Equal(windowBefore, flow.ConnectionSendWindow);
370369
}
371370

371+
[Fact(Timeout = 5000)]
372+
public void FailedRead_should_refund_reserved_window()
373+
{
374+
var target = new FakeTarget();
375+
var flow = MakeFlow();
376+
var scheduler = new FlowControlledBodyPump(target, flow, new CancellationTokenSource(), chunkSize: 1 * 1024, hardCap: 16);
377+
378+
flow.InitStreamSendWindow(1);
379+
var windowBefore = flow.ConnectionSendWindow;
380+
381+
// Async read path so the reservation is held while the read is in flight.
382+
var tcs = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
383+
var blockingStream = new DelegatingReadStream((_, _) => new ValueTask<int>(tcs.Task));
384+
scheduler.Register(1, blockingStream, null, CancellationToken.None);
385+
386+
Assert.Equal(windowBefore - 1 * 1024, flow.ConnectionSendWindow);
387+
388+
// Read fails while the stream is still active (not orphaned): nothing was
389+
// sent, so the full reservation must be refunded.
390+
scheduler.HandleReadFailed(1, new IOException("read failed"));
391+
392+
Assert.Single(target.Failed);
393+
Assert.Equal(windowBefore, flow.ConnectionSendWindow);
394+
}
395+
396+
[Fact(Timeout = 5000)]
397+
public void Cancel_should_dispose_queued_non_inflight_slot_immediately_even_when_window_stays_exhausted()
398+
{
399+
var target = new FakeTarget();
400+
var flow = MakeFlow(connWindow: 65535);
401+
// hardCap = 1: only one async read may be in flight at a time.
402+
var scheduler = new FlowControlledBodyPump(target, flow, new CancellationTokenSource(), chunkSize: 64, hardCap: 1);
403+
404+
flow.InitStreamSendWindow(1);
405+
flow.InitStreamSendWindow(3);
406+
407+
// Stream 1 occupies the only read slot with a read that never completes.
408+
var tcs = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
409+
var blockingStream = new DelegatingReadStream((_, _) => new ValueTask<int>(tcs.Task));
410+
scheduler.Register(1, blockingStream, null, CancellationToken.None);
411+
412+
// Stream 3 has an open window, so it is enqueued — but _asyncInFlight (1) already meets
413+
// hardCap (1), so it never starts a read: it sits in the ready queue, not in-flight and
414+
// not window-blocked.
415+
scheduler.Register(3, MakeBody(100), 100, CancellationToken.None);
416+
417+
var activeSlots = GetActiveSlots(scheduler);
418+
Assert.True(activeSlots.Contains(3));
419+
420+
// Cancel stream 3 while queued. The connection window never opens again afterward,
421+
// simulating a stalled connection — a deferred-dispose discipline would leave the
422+
// cancelled slot (and its pooled buffer) alive until Cleanup(); the correct discipline
423+
// disposes it immediately, matching MultiplexedBodyPump.
424+
scheduler.Cancel(3);
425+
426+
Assert.False(activeSlots.Contains(3));
427+
428+
// Opening the window afterward must not resurrect or emit anything for stream 3.
429+
flow.OnSendWindowUpdate(0, 65535);
430+
scheduler.OnWindowUpdate(0);
431+
432+
Assert.DoesNotContain(3, target.Completed);
433+
Assert.DoesNotContain(target.Emitted, e => e.StreamId == 3);
434+
}
435+
436+
private static IDictionary GetActiveSlots(FlowControlledBodyPump scheduler)
437+
{
438+
var field = typeof(FlowControlledBodyPump).GetField("_activeSlots", BindingFlags.NonPublic | BindingFlags.Instance)!;
439+
return (IDictionary)field.GetValue(scheduler)!;
440+
}
441+
372442
[Fact(Timeout = 5000)]
373443
public void WindowBlocked_should_block_when_available_below_half_chunk()
374444
{

src/GaudiHTTP.Tests/Protocol/Body/MultiplexedBodyPumpSpec.cs

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,6 @@ private static void DrainToCompletion(
6464
case BodyReadComplete<long> rc:
6565
pump.HandleReadComplete(rc.StreamId, rc.BytesRead);
6666
break;
67-
case BodyReadContinue<long> dc:
68-
pump.HandleBodyReadContinue(dc.StreamId);
69-
break;
7067
}
7168
}
7269
}
@@ -91,9 +88,6 @@ private static void DriveReadsWithoutCapacity(
9188
case BodyReadComplete<long> rc:
9289
pump.HandleReadComplete(rc.StreamId, rc.BytesRead);
9390
break;
94-
case BodyReadContinue<long> dc:
95-
pump.HandleBodyReadContinue(dc.StreamId);
96-
break;
9791
}
9892
}
9993
}
@@ -199,19 +193,18 @@ public void SyncFastPath_should_drain_small_body_inline()
199193
}
200194

201195
[Fact(Timeout = 5000)]
202-
public void Sync_reads_should_drain_body_larger_than_starvation_threshold()
196+
public void Many_consecutive_sync_reads_should_drain_fully()
203197
{
204198
var target = new FakeTarget();
205-
// Use small chunk so many sync reads happen
199+
// Small chunk so the body needs dozens of consecutive sync reads to drain.
206200
var pump = MakePump(target, chunkSize: 16);
207201

208202
pump.Register(1L, MakeBody(65 * 16), contentLength: null, CancellationToken.None);
209-
// Starvation guard fires at 64 consecutive reads and dispatches BodyReadContinue.
210-
// DrainToCompletion processes those messages to drive the pump to completion.
211203
DrainToCompletion(pump, target);
212204

213205
var total = target.Emitted.Where(e => !e.EndStream).Sum(e => e.Data.Length);
214-
Assert.True(total > 0, "Expected at least some data emitted before starvation guard fired");
206+
Assert.Equal(65 * 16, total);
207+
Assert.Single(target.Completed);
215208
}
216209

217210
[Fact(Timeout = 5000)]

src/GaudiHTTP.Tests/Protocol/Body/SerialBodyPumpSpec.cs

Lines changed: 3 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -60,17 +60,14 @@ private static void DrainToCompletion(
6060
case BodyReadComplete<int> rc:
6161
pump.HandleReadComplete(rc.BytesRead);
6262
break;
63-
case BodyReadContinue<int>:
64-
pump.HandleBodyReadContinue();
65-
break;
6663
}
6764
}
6865
}
6966

7067
/// <summary>
7168
/// Target that calls OnCapacityAvailable() synchronously after EmitDataFrames,
7269
/// simulating a consumer that immediately signals capacity after each chunk.
73-
/// Uses a direct-dispatch actor ref so pump messages (BodyReadComplete, BodyReadContinue)
70+
/// Uses a direct-dispatch actor ref so pump messages (BodyReadComplete)
7471
/// are processed inline as they arrive — no external drain loop needed.
7572
/// </summary>
7673
private sealed class AutoResumeTarget : IBodyDrainTarget
@@ -119,9 +116,6 @@ protected override void TellInternal(object message, IActorRef sender)
119116
case BodyReadComplete<int> rc:
120117
owner._pump.HandleReadComplete(rc.BytesRead);
121118
break;
122-
case BodyReadContinue<int>:
123-
owner._pump.HandleBodyReadContinue();
124-
break;
125119
}
126120
}
127121
}
@@ -193,9 +187,6 @@ public void OnCapacityAvailable_should_resume_drain_after_capacity_exhaustion()
193187
case BodyReadComplete<int> rc:
194188
pump.HandleReadComplete(rc.BytesRead);
195189
break;
196-
case BodyReadContinue<int>:
197-
pump.HandleBodyReadContinue();
198-
break;
199190
}
200191
}
201192

@@ -217,9 +208,6 @@ public void OnCapacityAvailable_should_resume_drain_after_capacity_exhaustion()
217208
case BodyReadComplete<int> rc:
218209
pump.HandleReadComplete(rc.BytesRead);
219210
break;
220-
case BodyReadContinue<int>:
221-
pump.HandleBodyReadContinue();
222-
break;
223211
}
224212
}
225213
}
@@ -299,19 +287,17 @@ public void Large_body_should_drain_fully_with_auto_resume()
299287
}
300288

301289
[Fact(Timeout = 5000)]
302-
public void Sync_reads_should_complete_large_body_with_starvation_guard_via_actor()
290+
public void Many_consecutive_sync_reads_should_drain_fully_with_auto_resume()
303291
{
304292
var target = new AutoResumeTarget();
305-
// Small chunk to trigger many sync reads; starvation guard will fire at 64 consecutive reads.
306-
// The AutoResumeTarget processes BodyReadContinue messages inline, so the drain completes fully.
293+
// Small chunk so the body needs dozens of consecutive sync reads to drain.
307294
var pump = MakePump(target, chunkSize: 16, maxCapacity: 2);
308295
target.SetPump(pump);
309296
var totalSize = 65 * 16;
310297
var body = MakeBody(totalSize);
311298

312299
pump.Register(body, contentLength: null, CancellationToken.None);
313300

314-
// AutoResumeTarget processes BodyReadContinue via DrainMessages() — full body drains.
315301
var emittedBytes = target.Emitted.Where(e => !e.EndStream).Sum(e => e.Data.Length);
316302
Assert.Equal(totalSize, emittedBytes);
317303
Assert.Single(target.Completed);
@@ -350,9 +336,6 @@ public void Capacity_grant_during_pending_completion_must_not_corrupt_buffer()
350336
case BodyReadComplete<int> rc:
351337
pump.HandleReadComplete(rc.BytesRead);
352338
break;
353-
case BodyReadContinue<int>:
354-
pump.HandleBodyReadContinue();
355-
break;
356339
}
357340
}
358341

0 commit comments

Comments
 (0)