Skip to content

Commit b6403d5

Browse files
authored
TestServer Reset support, fix up Abort #11598 (#11812)
1 parent 3f73a0f commit b6403d5

11 files changed

+373
-21
lines changed

src/Hosting/TestHost/ref/Microsoft.AspNetCore.TestHost.netcoreapp3.0.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ public static partial class HostBuilderTestServerExtensions
1414
public static System.Net.Http.HttpClient GetTestClient(this Microsoft.Extensions.Hosting.IHost host) { throw null; }
1515
public static Microsoft.AspNetCore.TestHost.TestServer GetTestServer(this Microsoft.Extensions.Hosting.IHost host) { throw null; }
1616
}
17+
public partial class HttpResetTestException : System.Exception
18+
{
19+
public HttpResetTestException(int errorCode) { }
20+
public int ErrorCode { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } }
21+
}
1722
public partial class RequestBuilder
1823
{
1924
public RequestBuilder(Microsoft.AspNetCore.TestHost.TestServer server, string path) { }

src/Hosting/TestHost/src/ClientHandler.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,15 @@ protected override async Task<HttpResponseMessage> SendAsync(
7070
{
7171
var req = context.Request;
7272

73-
req.Protocol = "HTTP/" + request.Version.ToString(fieldCount: 2);
73+
if (request.Version == HttpVersion.Version20)
74+
{
75+
// https://tools.ietf.org/html/rfc7540
76+
req.Protocol = "HTTP/2";
77+
}
78+
else
79+
{
80+
req.Protocol = "HTTP/" + request.Version.ToString(fieldCount: 2);
81+
}
7482
req.Method = request.Method.ToString();
7583

7684
req.Scheme = request.RequestUri.Scheme;

src/Hosting/TestHost/src/HttpContextBuilder.cs

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using System;
5+
using System.IO;
56
using System.IO.Pipelines;
67
using System.Threading;
78
using System.Threading.Tasks;
@@ -10,7 +11,7 @@
1011

1112
namespace Microsoft.AspNetCore.TestHost
1213
{
13-
internal class HttpContextBuilder : IHttpBodyControlFeature
14+
internal class HttpContextBuilder : IHttpBodyControlFeature, IHttpResetFeature
1415
{
1516
private readonly ApplicationWrapper _application;
1617
private readonly bool _preserveExecutionContext;
@@ -20,7 +21,7 @@ internal class HttpContextBuilder : IHttpBodyControlFeature
2021
private readonly ResponseBodyReaderStream _responseReaderStream;
2122
private readonly ResponseBodyPipeWriter _responsePipeWriter;
2223
private readonly ResponseFeature _responseFeature;
23-
private readonly RequestLifetimeFeature _requestLifetimeFeature = new RequestLifetimeFeature();
24+
private readonly RequestLifetimeFeature _requestLifetimeFeature;
2425
private readonly ResponseTrailersFeature _responseTrailersFeature = new ResponseTrailersFeature();
2526
private bool _pipelineFinished;
2627
private bool _returningResponse;
@@ -34,13 +35,14 @@ internal HttpContextBuilder(ApplicationWrapper application, bool allowSynchronou
3435
_preserveExecutionContext = preserveExecutionContext;
3536
_httpContext = new DefaultHttpContext();
3637
_responseFeature = new ResponseFeature(Abort);
38+
_requestLifetimeFeature = new RequestLifetimeFeature(Abort);
3739

3840
var request = _httpContext.Request;
3941
request.Protocol = "HTTP/1.1";
4042
request.Method = HttpMethods.Get;
4143

4244
var pipe = new Pipe();
43-
_responseReaderStream = new ResponseBodyReaderStream(pipe, AbortRequest, () => _responseReadCompleteCallback?.Invoke(_httpContext));
45+
_responseReaderStream = new ResponseBodyReaderStream(pipe, ClientInitiatedAbort, () => _responseReadCompleteCallback?.Invoke(_httpContext));
4446
_responsePipeWriter = new ResponseBodyPipeWriter(pipe, ReturnResponseMessageAsync);
4547
_responseFeature.Body = new ResponseBodyWriterStream(_responsePipeWriter, () => AllowSynchronousIO);
4648
_responseFeature.BodySnapshot = _responseFeature.Body;
@@ -77,11 +79,17 @@ internal void RegisterResponseReadCompleteCallback(Action<HttpContext> responseR
7779
/// <returns></returns>
7880
internal Task<HttpContext> SendAsync(CancellationToken cancellationToken)
7981
{
80-
var registration = cancellationToken.Register(AbortRequest);
82+
var registration = cancellationToken.Register(ClientInitiatedAbort);
8183

8284
// Everything inside this function happens in the SERVER's execution context (unless PreserveExecutionContext is true)
8385
async Task RunRequestAsync()
8486
{
87+
// HTTP/2 specific features must be added after the request has been configured.
88+
if (string.Equals("HTTP/2", _httpContext.Request.Protocol, StringComparison.OrdinalIgnoreCase))
89+
{
90+
_httpContext.Features.Set<IHttpResetFeature>(this);
91+
}
92+
8593
// This will configure IHttpContextAccessor so it needs to happen INSIDE this function,
8694
// since we are now inside the Server's execution context. If it happens outside this cont
8795
// it will be lost when we abandon the execution context.
@@ -120,13 +128,16 @@ async Task RunRequestAsync()
120128
return _responseTcs.Task;
121129
}
122130

123-
internal void AbortRequest()
131+
// Triggered by request CancellationToken canceling or response stream Disposal.
132+
internal void ClientInitiatedAbort()
124133
{
125134
if (!_pipelineFinished)
126135
{
127-
_requestLifetimeFeature.Abort();
136+
// We don't want to trigger the token for already completed responses.
137+
_requestLifetimeFeature.Cancel();
128138
}
129-
_responsePipeWriter.Complete();
139+
// Writes will still succeed, the app will only get an error if they check the CT.
140+
_responseReaderStream.Abort(new IOException("The client aborted the request."));
130141
}
131142

132143
internal async Task CompleteResponseAsync()
@@ -178,10 +189,15 @@ internal async Task ReturnResponseMessageAsync()
178189

179190
internal void Abort(Exception exception)
180191
{
181-
_pipelineFinished = true;
182192
_responsePipeWriter.Abort(exception);
183193
_responseReaderStream.Abort(exception);
194+
_requestLifetimeFeature.Cancel();
184195
_responseTcs.TrySetException(exception);
185196
}
197+
198+
void IHttpResetFeature.Reset(int errorCode)
199+
{
200+
Abort(new HttpResetTestException(errorCode));
201+
}
186202
}
187203
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System;
5+
using Microsoft.AspNetCore.Http.Features;
6+
7+
namespace Microsoft.AspNetCore.TestHost
8+
{
9+
/// <summary>
10+
/// Used to surface to the test client that the application invoked <see cref="IHttpResetFeature.Reset"/>
11+
/// </summary>
12+
public class HttpResetTestException : Exception
13+
{
14+
/// <summary>
15+
/// Creates a new test exception
16+
/// </summary>
17+
/// <param name="errorCode">The error code passed to <see cref="IHttpResetFeature.Reset"/></param>
18+
public HttpResetTestException(int errorCode)
19+
: base($"The application reset the request with error code {errorCode}.")
20+
{
21+
ErrorCode = errorCode;
22+
}
23+
24+
/// <summary>
25+
/// The error code passed to <see cref="IHttpResetFeature.Reset"/>
26+
/// </summary>
27+
public int ErrorCode { get; }
28+
}
29+
}
Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

4+
using System;
45
using System.Threading;
56
using Microsoft.AspNetCore.Http.Features;
67

@@ -9,14 +10,25 @@ namespace Microsoft.AspNetCore.TestHost
910
internal class RequestLifetimeFeature : IHttpRequestLifetimeFeature
1011
{
1112
private readonly CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource();
13+
private readonly Action<Exception> _abort;
1214

13-
public RequestLifetimeFeature()
15+
public RequestLifetimeFeature(Action<Exception> abort)
1416
{
1517
RequestAborted = _cancellationTokenSource.Token;
18+
_abort = abort;
1619
}
1720

1821
public CancellationToken RequestAborted { get; set; }
1922

20-
public void Abort() => _cancellationTokenSource.Cancel();
23+
internal void Cancel()
24+
{
25+
_cancellationTokenSource.Cancel();
26+
}
27+
28+
void IHttpRequestLifetimeFeature.Abort()
29+
{
30+
_abort(new Exception("The application aborted the request."));
31+
_cancellationTokenSource.Cancel();
32+
}
2133
}
2234
}

src/Hosting/TestHost/src/ResponseBodyReaderStream.cs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,11 @@ public async override Task<int> ReadAsync(byte[] buffer, int offset, int count,
8080
using var registration = cancellationToken.Register(Cancel);
8181
var result = await _pipe.Reader.ReadAsync(cancellationToken);
8282

83+
if (result.IsCanceled)
84+
{
85+
throw new OperationCanceledException();
86+
}
87+
8388
if (result.Buffer.IsEmpty && result.IsCompleted)
8489
{
8590
_pipe.Reader.Complete();
@@ -114,16 +119,16 @@ private static void VerifyBuffer(byte[] buffer, int offset, int count)
114119

115120
internal void Cancel()
116121
{
117-
_aborted = true;
118-
_abortException = new OperationCanceledException();
119-
_pipe.Writer.Complete(_abortException);
122+
Abort(new OperationCanceledException());
120123
}
121124

122125
internal void Abort(Exception innerException)
123126
{
124127
Contract.Requires(innerException != null);
125128
_aborted = true;
126129
_abortException = innerException;
130+
_pipe.Reader.CancelPendingRead();
131+
_pipe.Reader.Complete();
127132
}
128133

129134
private void CheckAborted()

src/Hosting/TestHost/test/ClientHandlerTests.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -301,8 +301,7 @@ public async Task ClientDisposalCloses()
301301
Task<int> readTask = responseStream.ReadAsync(new byte[100], 0, 100);
302302
Assert.False(readTask.IsCompleted);
303303
responseStream.Dispose();
304-
var read = await readTask.WithTimeout();
305-
Assert.Equal(0, read);
304+
await Assert.ThrowsAsync<OperationCanceledException>(() => readTask.WithTimeout());
306305
block.SetResult(0);
307306
}
308307

src/Hosting/TestHost/test/HttpContextBuilderTests.cs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -194,8 +194,7 @@ public async Task ClientDisposalCloses()
194194
Task<int> readTask = responseStream.ReadAsync(new byte[100], 0, 100);
195195
Assert.False(readTask.IsCompleted);
196196
responseStream.Dispose();
197-
var read = await readTask.WithTimeout();
198-
Assert.Equal(0, read);
197+
await Assert.ThrowsAsync<OperationCanceledException>(() => readTask.WithTimeout());
199198
block.SetResult(0);
200199
}
201200

@@ -313,19 +312,22 @@ public async Task ClientHandlerCreateContextWithDefaultRequestParameters()
313312
[Fact]
314313
public async Task CallingAbortInsideHandlerShouldSetRequestAborted()
315314
{
315+
var requestAborted = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
316316
var builder = new WebHostBuilder()
317317
.Configure(app =>
318318
{
319319
app.Run(context =>
320320
{
321+
context.RequestAborted.Register(() => requestAborted.SetResult(0));
321322
context.Abort();
322323
return Task.CompletedTask;
323324
});
324325
});
325326
var server = new TestServer(builder);
326327

327-
var ctx = await server.SendAsync(c => { });
328-
Assert.True(ctx.RequestAborted.IsCancellationRequested);
328+
var ex = await Assert.ThrowsAsync<Exception>(() => server.SendAsync(c => { }));
329+
Assert.Equal("The application aborted the request.", ex.Message);
330+
await requestAborted.Task.WithTimeout();
329331
}
330332

331333
private class VerifierLogger : ILogger<IWebHost>
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.Net.Http;
6+
using System.Threading.Tasks;
7+
using Microsoft.AspNetCore.Builder;
8+
using Microsoft.AspNetCore.Hosting;
9+
using Microsoft.AspNetCore.Http;
10+
using Microsoft.AspNetCore.Http.Features;
11+
using Microsoft.Extensions.Hosting;
12+
using Xunit;
13+
14+
namespace Microsoft.AspNetCore.TestHost
15+
{
16+
public class RequestLifetimeTests
17+
{
18+
[Fact]
19+
public async Task LifetimeFeature_Abort_TriggersRequestAbortedToken()
20+
{
21+
var requestAborted = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
22+
using var host = await CreateHost(async httpContext =>
23+
{
24+
httpContext.RequestAborted.Register(() => requestAborted.SetResult(0));
25+
httpContext.Abort();
26+
27+
await requestAborted.Task.WithTimeout();
28+
});
29+
30+
var client = host.GetTestServer().CreateClient();
31+
var ex = await Assert.ThrowsAsync<Exception>(() => client.GetAsync("/", HttpCompletionOption.ResponseHeadersRead));
32+
Assert.Equal("The application aborted the request.", ex.Message);
33+
await requestAborted.Task.WithTimeout();
34+
}
35+
36+
[Fact]
37+
public async Task LifetimeFeature_AbortBeforeHeadersSent_ClientThrows()
38+
{
39+
var abortReceived = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
40+
using var host = await CreateHost(async httpContext =>
41+
{
42+
httpContext.Abort();
43+
await abortReceived.Task.WithTimeout();
44+
});
45+
46+
var client = host.GetTestServer().CreateClient();
47+
var ex = await Assert.ThrowsAsync<Exception>(() => client.GetAsync("/", HttpCompletionOption.ResponseHeadersRead));
48+
Assert.Equal("The application aborted the request.", ex.Message);
49+
abortReceived.SetResult(0);
50+
}
51+
52+
[Fact]
53+
public async Task LifetimeFeature_AbortAfterHeadersSent_ClientBodyThrows()
54+
{
55+
var responseReceived = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
56+
var abortReceived = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
57+
using var host = await CreateHost(async httpContext =>
58+
{
59+
await httpContext.Response.Body.FlushAsync();
60+
await responseReceived.Task.WithTimeout();
61+
httpContext.Abort();
62+
await abortReceived.Task.WithTimeout();
63+
});
64+
65+
var client = host.GetTestServer().CreateClient();
66+
var response = await client.GetAsync("/", HttpCompletionOption.ResponseHeadersRead);
67+
responseReceived.SetResult(0);
68+
response.EnsureSuccessStatusCode();
69+
var ex = await Assert.ThrowsAsync<HttpRequestException>(() => response.Content.ReadAsByteArrayAsync());
70+
var rex = ex.GetBaseException();
71+
Assert.Equal("The application aborted the request.", rex.Message);
72+
abortReceived.SetResult(0);
73+
}
74+
75+
[Fact]
76+
public async Task LifetimeFeature_AbortAfterSomeDataSent_ClientBodyThrows()
77+
{
78+
var responseReceived = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
79+
var abortReceived = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
80+
using var host = await CreateHost(async httpContext =>
81+
{
82+
await httpContext.Response.WriteAsync("Hello World");
83+
await responseReceived.Task.WithTimeout();
84+
httpContext.Abort();
85+
await abortReceived.Task.WithTimeout();
86+
});
87+
88+
var client = host.GetTestServer().CreateClient();
89+
var response = await client.GetAsync("/", HttpCompletionOption.ResponseHeadersRead);
90+
responseReceived.SetResult(0);
91+
response.EnsureSuccessStatusCode();
92+
var ex = await Assert.ThrowsAsync<HttpRequestException>(() => response.Content.ReadAsByteArrayAsync());
93+
var rex = ex.GetBaseException();
94+
Assert.Equal("The application aborted the request.", rex.Message);
95+
abortReceived.SetResult(0);
96+
}
97+
98+
// TODO: Abort after CompleteAsync - No-op, the request is already complete.
99+
100+
private Task<IHost> CreateHost(RequestDelegate appDelegate)
101+
{
102+
return new HostBuilder()
103+
.ConfigureWebHost(webBuilder =>
104+
{
105+
webBuilder
106+
.UseTestServer()
107+
.Configure(app =>
108+
{
109+
app.Run(appDelegate);
110+
});
111+
})
112+
.StartAsync();
113+
}
114+
}
115+
}

0 commit comments

Comments
 (0)