Skip to content

Commit e244296

Browse files
committed
HTTP/3: Fix incorrectly pooling aborted streams
1 parent 520a342 commit e244296

File tree

4 files changed

+115
-3
lines changed

4 files changed

+115
-3
lines changed

src/Servers/Kestrel/Transport.Quic/src/Internal/QuicStreamContext.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -415,11 +415,15 @@ private void ShutdownWrite(Exception? shutdownReason)
415415

416416
public override async ValueTask DisposeAsync()
417417
{
418+
// Be conservative about what can be pooled.
419+
// Only pool bidirectional streams whose pipes have completed successfully and haven't been aborted.
418420
CanReuse = _stream.CanRead && _stream.CanWrite
419421
&& _transportPipeReader.IsCompletedSuccessfully
420422
&& _transportPipeWriter.IsCompletedSuccessfully
421423
&& !_clientAbort
422-
&& !_serverAborted;
424+
&& !_serverAborted
425+
&& _shutdownReadReason == null
426+
&& _shutdownWriteReason == null;
423427

424428
_originalTransport.Input.Complete();
425429
_originalTransport.Output.Complete();

src/Servers/Kestrel/Transport.Quic/test/QuicConnectionListenerTests.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,8 +115,7 @@ public async Task ClientCertificate_Required_NotSent_ConnectionAborted()
115115

116116
// https://github.com/dotnet/runtime/issues/57246 The accept still completes even though the connection was rejected, but it's already failed.
117117
var serverContext = await connectionListener.AcceptAndAddFeatureAsync().DefaultTimeout();
118-
qex = await Assert.ThrowsAsync<QuicException>(() => serverContext.ConnectAsync().DefaultTimeout());
119-
Assert.Equal("Failed to open stream to peer. Error Code: INVALID_STATE", qex.Message);
118+
await Assert.ThrowsAsync<QuicException>(() => serverContext.ConnectAsync().DefaultTimeout());
120119
}
121120
}
122121
}

src/Servers/Kestrel/Transport.Quic/test/QuicStreamContextTests.cs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,52 @@ public async Task BidirectionalStream_ServerReadsDataAndCompletes_GracefullyClos
4949
Assert.Contains(TestSink.Writes, m => m.Message.Contains(@"shutting down writes because: ""The QUIC transport's send loop completed gracefully.""."));
5050
}
5151

52+
[ConditionalFact]
53+
[MsQuicSupported]
54+
public async Task BidirectionalStream_ReadAborted_NotPooled()
55+
{
56+
// Arrange
57+
await using var connectionListener = await QuicTestHelpers.CreateConnectionListenerFactory(LoggerFactory);
58+
59+
var options = QuicTestHelpers.CreateClientConnectionOptions(connectionListener.EndPoint);
60+
using var clientConnection = new QuicConnection(QuicImplementationProviders.MsQuic, options);
61+
await clientConnection.ConnectAsync().DefaultTimeout();
62+
63+
await using var serverConnection = await connectionListener.AcceptAndAddFeatureAsync().DefaultTimeout();
64+
65+
// Act
66+
var clientStream = clientConnection.OpenBidirectionalStream();
67+
await clientStream.WriteAsync(TestData).DefaultTimeout();
68+
var serverStream = await serverConnection.AcceptAsync().DefaultTimeout();
69+
var readResult = await serverStream.Transport.Input.ReadAtLeastAsync(TestData.Length).DefaultTimeout();
70+
serverStream.Transport.Input.AdvanceTo(readResult.Buffer.End);
71+
72+
await clientStream.WriteAsync(TestData).DefaultTimeout();
73+
74+
// Complete writing.
75+
await serverStream.Transport.Output.CompleteAsync();
76+
77+
// Abort read-side of the stream and then complete pipe.
78+
// This simulates what Kestrel does when a request finishes without
79+
// reading the request body to the end.
80+
serverStream.Features.Get<IStreamAbortFeature>().AbortRead((long)Http3ErrorCode.NoError, new ConnectionAbortedException("Test message."));
81+
await serverStream.Transport.Input.CompleteAsync();
82+
83+
var quicStreamContext = Assert.IsType<QuicStreamContext>(serverStream);
84+
85+
// Both send and receive loops have exited.
86+
await quicStreamContext._processingTask.DefaultTimeout();
87+
Assert.True(quicStreamContext.CanWrite);
88+
Assert.True(quicStreamContext.CanRead);
89+
90+
await quicStreamContext.DisposeAsync();
91+
92+
var quicConnectionContext = Assert.IsType<QuicConnectionContext>(serverConnection);
93+
94+
// Assert
95+
Assert.Equal(0, quicConnectionContext.StreamPool.Count);
96+
}
97+
5298
[ConditionalTheory]
5399
[MsQuicSupported]
54100
[InlineData(1024)]

src/Servers/Kestrel/test/Interop.FunctionalTests/Http3/Http3RequestTests.cs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -514,6 +514,69 @@ public async Task GET_MultipleRequestsInSequence_ReusedState()
514514
}
515515
}
516516

517+
[ConditionalFact]
518+
[MsQuicSupported]
519+
public async Task StreamResponseContent_DelayAndTrailers_ClientSuccess()
520+
{
521+
// Arrange
522+
var builder = CreateHostBuilder(async context =>
523+
{
524+
var feature = context.Features.Get<IHttpResponseTrailersFeature>();
525+
526+
for (var i = 1; i < 200; i++)
527+
{
528+
feature.Trailers.Append($"trailer-{i}", new string('!', i));
529+
}
530+
531+
Logger.LogInformation($"Server trailer count: {feature.Trailers.Count}");
532+
533+
await context.Request.BodyReader.ReadAtLeastAsync(TestData.Length);
534+
535+
for (var i = 0; i < 3; i++)
536+
{
537+
await context.Response.BodyWriter.WriteAsync(TestData);
538+
539+
await Task.Delay(TimeSpan.FromMilliseconds(10));
540+
}
541+
});
542+
543+
using (var host = builder.Build())
544+
using (var client = Http3Helpers.CreateClient())
545+
{
546+
await host.StartAsync();
547+
548+
// Act
549+
var request = new HttpRequestMessage(HttpMethod.Post, $"https://127.0.0.1:{host.GetPort()}/");
550+
request.Content = new ByteArrayContent(TestData);
551+
request.Version = HttpVersion.Version30;
552+
request.VersionPolicy = HttpVersionPolicy.RequestVersionExact;
553+
554+
var response = await client.SendAsync(request, CancellationToken.None);
555+
response.EnsureSuccessStatusCode();
556+
557+
var responseStream = await response.Content.ReadAsStreamAsync();
558+
559+
await responseStream.ReadUntilEndAsync();
560+
561+
Logger.LogInformation($"Client trailer count: {response.TrailingHeaders.Count()}");
562+
563+
for (var i = 1; i < 200; i++)
564+
{
565+
try
566+
{
567+
var value = response.TrailingHeaders.GetValues($"trailer-{i}").Single();
568+
Assert.Equal(new string('!', i), value);
569+
}
570+
catch (Exception ex)
571+
{
572+
throw new Exception($"Error checking trailer {i}", ex);
573+
}
574+
}
575+
576+
await host.StopAsync();
577+
}
578+
}
579+
517580
[ConditionalFact]
518581
[MsQuicSupported]
519582
public async Task GET_MultipleRequests_ConnectionAndTraceIdsUpdated()

0 commit comments

Comments
 (0)