Skip to content

Commit 8e1c192

Browse files
authored
HTTP/3: Raise RequestAbort on client cancellation (#34675)
1 parent 33c24dc commit 8e1c192

File tree

5 files changed

+259
-10
lines changed

5 files changed

+259
-10
lines changed

src/Servers/Kestrel/Core/src/Internal/Http3/Http3Stream.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using System.IO.Pipelines;
88
using System.Net.Http;
99
using System.Net.Http.QPack;
10+
using System.Net.Quic;
1011
using System.Runtime.CompilerServices;
1112
using System.Threading;
1213
using System.Threading.Tasks;
@@ -440,7 +441,6 @@ public async Task ProcessRequestAsync<TContext>(IHttpApplication<TContext> appli
440441
}
441442
}
442443
}
443-
// catch ConnectionResetException here?
444444
catch (Http3StreamErrorException ex)
445445
{
446446
error = ex;
@@ -453,6 +453,15 @@ public async Task ProcessRequestAsync<TContext>(IHttpApplication<TContext> appli
453453

454454
_context.StreamLifetimeHandler.OnStreamConnectionError(ex);
455455
}
456+
catch (ConnectionResetException ex)
457+
{
458+
// TODO: This is temporary. Don't want to tie HTTP/3 layer to one transport.
459+
// This is here to check what other exceptions can cause ConnectionResetException.
460+
Debug.Assert(ex.InnerException is QuicStreamAbortedException);
461+
462+
error = ex;
463+
Abort(new ConnectionAbortedException(ex.Message, ex), (Http3ErrorCode)_errorCodeFeature.Error);
464+
}
456465
catch (Exception ex)
457466
{
458467
error = ex;

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ private async Task DoReceive()
189189
catch (QuicStreamAbortedException ex)
190190
{
191191
// Abort from peer.
192+
Error = ex.ErrorCode;
192193
_log.StreamAborted(this, ex);
193194

194195
// This could be ignored if _shutdownReason is already set.
@@ -304,6 +305,7 @@ private async Task DoSend()
304305
catch (QuicStreamAbortedException ex)
305306
{
306307
// Abort from peer.
308+
Error = ex.ErrorCode;
307309
_log.StreamAborted(this, ex);
308310

309311
// This could be ignored if _shutdownReason is already set.

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,8 @@ public async Task BidirectionalStream_ClientAbortWrite_ServerReceivesAbort()
106106

107107
var quicStreamContext = Assert.IsType<QuicStreamContext>(serverStream);
108108

109+
Assert.Equal((long)Http3ErrorCode.InternalError, quicStreamContext.Error);
110+
109111
// Both send and receive loops have exited.
110112
await quicStreamContext._processingTask.DefaultTimeout();
111113

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

Lines changed: 244 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,15 @@
33

44
using System.Net;
55
using System.Net.Http;
6+
using System.Net.Http.Headers;
7+
using System.Net.Quic;
68
using System.Text;
79
using Microsoft.AspNetCore.Builder;
810
using Microsoft.AspNetCore.Connections;
911
using Microsoft.AspNetCore.Connections.Features;
1012
using Microsoft.AspNetCore.Hosting;
1113
using Microsoft.AspNetCore.Http;
14+
using Microsoft.AspNetCore.Internal;
1215
using Microsoft.AspNetCore.Server.Kestrel.Core;
1316
using Microsoft.AspNetCore.Server.Kestrel.FunctionalTests;
1417
using Microsoft.AspNetCore.Testing;
@@ -24,11 +27,19 @@ private class StreamingHttpContext : HttpContent
2427
private readonly TaskCompletionSource _completeTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
2528
private readonly TaskCompletionSource<Stream> _getStreamTcs = new TaskCompletionSource<Stream>(TaskCreationOptions.RunContinuationsAsynchronously);
2629

27-
protected override async Task SerializeToStreamAsync(Stream stream, TransportContext context)
30+
protected override Task SerializeToStreamAsync(Stream stream, TransportContext context)
31+
{
32+
throw new NotSupportedException();
33+
}
34+
35+
protected override async Task SerializeToStreamAsync(Stream stream, TransportContext context, CancellationToken cancellationToken)
2836
{
2937
_getStreamTcs.TrySetResult(stream);
3038

31-
await _completeTcs.Task;
39+
var cancellationTcs = new TaskCompletionSource();
40+
cancellationToken.Register(() => cancellationTcs.TrySetCanceled());
41+
42+
await Task.WhenAny(_completeTcs.Task, cancellationTcs.Task);
3243
}
3344

3445
protected override bool TryComputeLength(out long length)
@@ -52,10 +63,10 @@ public void CompleteStream()
5263

5364
[ConditionalFact]
5465
[MsQuicSupported]
55-
public async Task POST_ServerCompletsWithoutReadingRequestBody_ClientGetsResponse()
66+
public async Task POST_ServerCompletesWithoutReadingRequestBody_ClientGetsResponse()
5667
{
5768
// Arrange
58-
var builder = CreateHttp3HostBuilder(async context =>
69+
var builder = CreateHostBuilder(async context =>
5970
{
6071
var body = context.Request.Body;
6172

@@ -108,6 +119,168 @@ public async Task POST_ServerCompletsWithoutReadingRequestBody_ClientGetsRespons
108119
}
109120
}
110121

122+
// Verify HTTP/2 and HTTP/3 match behavior
123+
[ConditionalTheory]
124+
[MsQuicSupported]
125+
[InlineData(HttpProtocols.Http3)]
126+
[InlineData(HttpProtocols.Http2)]
127+
public async Task POST_ClientCancellationUpload_RequestAbortRaised(HttpProtocols protocol)
128+
{
129+
// Arrange
130+
var syncPoint = new SyncPoint();
131+
var cancelledTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
132+
var readAsyncTask = new TaskCompletionSource<Task>(TaskCreationOptions.RunContinuationsAsynchronously);
133+
134+
var builder = CreateHostBuilder(async context =>
135+
{
136+
context.RequestAborted.Register(() => cancelledTcs.SetResult());
137+
138+
var body = context.Request.Body;
139+
140+
// Read content
141+
var data = new List<byte>();
142+
var buffer = new byte[1024];
143+
var readCount = 0;
144+
while ((readCount = await body.ReadAsync(buffer).DefaultTimeout()) != -1)
145+
{
146+
data.AddRange(buffer.AsMemory(0, readCount).ToArray());
147+
if (data.Count == TestData.Length)
148+
{
149+
break;
150+
}
151+
}
152+
153+
// Sync with client
154+
await syncPoint.WaitToContinue();
155+
156+
// Wait for task cancellation
157+
await cancelledTcs.Task;
158+
159+
readAsyncTask.SetResult(body.ReadAsync(buffer).AsTask());
160+
}, protocol: protocol);
161+
162+
var httpClientHandler = new HttpClientHandler();
163+
httpClientHandler.ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator;
164+
165+
using (var host = builder.Build())
166+
using (var client = new HttpClient(httpClientHandler))
167+
{
168+
await host.StartAsync().DefaultTimeout();
169+
170+
var cts = new CancellationTokenSource();
171+
var requestContent = new StreamingHttpContext();
172+
173+
var request = new HttpRequestMessage(HttpMethod.Post, $"https://127.0.0.1:{host.GetPort()}/");
174+
request.Content = requestContent;
175+
request.Version = GetProtocol(protocol);
176+
request.VersionPolicy = HttpVersionPolicy.RequestVersionExact;
177+
178+
// Act
179+
var responseTask = client.SendAsync(request, cts.Token);
180+
181+
var requestStream = await requestContent.GetStreamAsync().DefaultTimeout();
182+
183+
// Send headers
184+
await requestStream.FlushAsync().DefaultTimeout();
185+
// Write content
186+
await requestStream.WriteAsync(TestData).DefaultTimeout();
187+
188+
// Wait until content is read on server
189+
await syncPoint.WaitForSyncPoint().DefaultTimeout();
190+
191+
// Cancel request
192+
cts.Cancel();
193+
194+
// Continue on server
195+
syncPoint.Continue();
196+
197+
// Assert
198+
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => responseTask).DefaultTimeout();
199+
200+
await cancelledTcs.Task.DefaultTimeout();
201+
202+
var serverWriteTask = await readAsyncTask.Task.DefaultTimeout();
203+
204+
await Assert.ThrowsAnyAsync<Exception>(() => serverWriteTask).DefaultTimeout();
205+
206+
await host.StopAsync().DefaultTimeout();
207+
}
208+
}
209+
210+
// Verify HTTP/2 and HTTP/3 match behavior
211+
[ConditionalTheory]
212+
[MsQuicSupported]
213+
[InlineData(HttpProtocols.Http3)]
214+
[InlineData(HttpProtocols.Http2)]
215+
public async Task GET_ServerAbort_ClientReceivesAbort(HttpProtocols protocol)
216+
{
217+
// Arrange
218+
var syncPoint = new SyncPoint();
219+
var cancelledTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
220+
var writeAsyncTask = new TaskCompletionSource<Task>(TaskCreationOptions.RunContinuationsAsynchronously);
221+
222+
var builder = CreateHostBuilder(async context =>
223+
{
224+
context.RequestAborted.Register(() => cancelledTcs.SetResult());
225+
226+
context.Abort();
227+
228+
// Sync with client
229+
await syncPoint.WaitToContinue();
230+
231+
writeAsyncTask.SetResult(context.Response.Body.WriteAsync(TestData).AsTask());
232+
}, protocol: protocol);
233+
234+
var httpClientHandler = new HttpClientHandler();
235+
httpClientHandler.ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator;
236+
237+
using (var host = builder.Build())
238+
using (var client = new HttpClient(httpClientHandler))
239+
{
240+
await host.StartAsync().DefaultTimeout();
241+
242+
var requestContent = new StreamingHttpContext();
243+
244+
var request = new HttpRequestMessage(HttpMethod.Get, $"https://127.0.0.1:{host.GetPort()}/");
245+
request.Version = GetProtocol(protocol);
246+
request.VersionPolicy = HttpVersionPolicy.RequestVersionExact;
247+
248+
// Act
249+
var ex = await Assert.ThrowsAnyAsync<HttpRequestException>(() => client.SendAsync(request)).DefaultTimeout();
250+
251+
// Assert
252+
if (protocol == HttpProtocols.Http3)
253+
{
254+
var innerEx = Assert.IsType<QuicStreamAbortedException>(ex.InnerException);
255+
Assert.Equal(258, innerEx.ErrorCode);
256+
}
257+
258+
await cancelledTcs.Task.DefaultTimeout();
259+
260+
// Sync with server to ensure RequestDelegate is still running
261+
await syncPoint.WaitForSyncPoint().DefaultTimeout();
262+
syncPoint.Continue();
263+
264+
var serverWriteTask = await writeAsyncTask.Task.DefaultTimeout();
265+
await serverWriteTask.DefaultTimeout();
266+
267+
await host.StopAsync().DefaultTimeout();
268+
}
269+
}
270+
271+
private static Version GetProtocol(HttpProtocols protocol)
272+
{
273+
switch (protocol)
274+
{
275+
case HttpProtocols.Http2:
276+
return HttpVersion.Version20;
277+
case HttpProtocols.Http3:
278+
return HttpVersion.Version30;
279+
default:
280+
throw new InvalidOperationException();
281+
}
282+
}
283+
111284
[ConditionalFact]
112285
[MsQuicSupported]
113286
public async Task GET_MultipleRequestsInSequence_ReusedState()
@@ -116,7 +289,7 @@ public async Task GET_MultipleRequestsInSequence_ReusedState()
116289
object persistedState = null;
117290
var requestCount = 0;
118291

119-
var builder = CreateHttp3HostBuilder(context =>
292+
var builder = CreateHostBuilder(context =>
120293
{
121294
requestCount++;
122295
var persistentStateCollection = context.Features.Get<IPersistentStateFeature>().State;
@@ -165,17 +338,79 @@ public async Task GET_MultipleRequestsInSequence_ReusedState()
165338
}
166339
}
167340

341+
[ConditionalFact]
342+
[MsQuicSupported]
343+
public async Task GET_MultipleRequestsInSequence_ReusedRequestHeaderStrings()
344+
{
345+
// Arrange
346+
string request1HeaderValue = null;
347+
string request2HeaderValue = null;
348+
var requestCount = 0;
349+
350+
var builder = CreateHostBuilder(context =>
351+
{
352+
requestCount++;
353+
354+
if (requestCount == 1)
355+
{
356+
request1HeaderValue = context.Request.Headers.UserAgent;
357+
}
358+
else if (requestCount == 2)
359+
{
360+
request2HeaderValue = context.Request.Headers.UserAgent;
361+
}
362+
else
363+
{
364+
throw new InvalidOperationException();
365+
}
366+
367+
return Task.CompletedTask;
368+
});
369+
370+
using (var host = builder.Build())
371+
using (var client = CreateClient())
372+
{
373+
await host.StartAsync();
374+
375+
// Act
376+
var request1 = new HttpRequestMessage(HttpMethod.Get, $"https://127.0.0.1:{host.GetPort()}/");
377+
request1.Headers.TryAddWithoutValidation("User-Agent", "TestUserAgent");
378+
request1.Version = HttpVersion.Version30;
379+
request1.VersionPolicy = HttpVersionPolicy.RequestVersionExact;
380+
381+
var response1 = await client.SendAsync(request1);
382+
response1.EnsureSuccessStatusCode();
383+
384+
// Delay to ensure the stream has enough time to return to pool
385+
await Task.Delay(100);
386+
387+
var request2 = new HttpRequestMessage(HttpMethod.Get, $"https://127.0.0.1:{host.GetPort()}/");
388+
request2.Headers.TryAddWithoutValidation("User-Agent", "TestUserAgent");
389+
request2.Version = HttpVersion.Version30;
390+
request2.VersionPolicy = HttpVersionPolicy.RequestVersionExact;
391+
392+
var response2 = await client.SendAsync(request2);
393+
response2.EnsureSuccessStatusCode();
394+
395+
// Assert
396+
Assert.Equal("TestUserAgent", request1HeaderValue);
397+
Assert.Same(request1HeaderValue, request2HeaderValue);
398+
399+
await host.StopAsync();
400+
}
401+
}
402+
168403
[ConditionalFact]
169404
[MsQuicSupported]
170405
public async Task GET_ConnectionLoggingConfigured_OutputToLogs()
171406
{
172407
// Arrange
173-
var builder = CreateHttp3HostBuilder(
408+
var builder = CreateHostBuilder(
174409
context =>
175410
{
176411
return Task.CompletedTask;
177412
},
178-
kestrel =>
413+
configureKestrel: kestrel =>
179414
{
180415
kestrel.ListenLocalhost(5001, listenOptions =>
181416
{
@@ -223,7 +458,7 @@ private static HttpClient CreateClient()
223458
return new HttpClient(httpHandler);
224459
}
225460

226-
private IHostBuilder CreateHttp3HostBuilder(RequestDelegate requestDelegate, Action<KestrelServerOptions> configureKestrel = null)
461+
private IHostBuilder CreateHostBuilder(RequestDelegate requestDelegate, HttpProtocols? protocol = null, Action<KestrelServerOptions> configureKestrel = null)
227462
{
228463
return GetHostBuilder()
229464
.ConfigureWebHost(webHostBuilder =>
@@ -235,7 +470,7 @@ private IHostBuilder CreateHttp3HostBuilder(RequestDelegate requestDelegate, Act
235470
{
236471
o.Listen(IPAddress.Parse("127.0.0.1"), 0, listenOptions =>
237472
{
238-
listenOptions.Protocols = HttpProtocols.Http3;
473+
listenOptions.Protocols = protocol ?? HttpProtocols.Http3;
239474
listenOptions.UseHttps();
240475
});
241476
}

src/Servers/Kestrel/test/Interop.FunctionalTests/Interop.FunctionalTests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
<ItemGroup>
1414
<Compile Include="$(SharedSourceRoot)NullScope.cs" />
15+
<Compile Include="$(SharedSourceRoot)SyncPoint\SyncPoint.cs" Link="SyncPoint.cs" />
1516
<Compile Include="$(KestrelSharedSourceRoot)test\TransportTestHelpers\IHostPortExtensions.cs" LinkBase="shared" />
1617
<Compile Include="$(KestrelSharedSourceRoot)test\TransportTestHelpers\IWebHostPortExtensions.cs" LinkBase="shared" />
1718
<Compile Include="$(KestrelSharedSourceRoot)test\TestConstants.cs" LinkBase="shared" />

0 commit comments

Comments
 (0)