Skip to content

Commit cfea2e9

Browse files
authored
Fix TestServer from blocking on request stream (#15591)
1 parent 3ceca46 commit cfea2e9

File tree

7 files changed

+399
-36
lines changed

7 files changed

+399
-36
lines changed

src/Hosting/TestHost/src/ClientHandler.cs

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using System.Collections.Generic;
66
using System.Diagnostics.Contracts;
77
using System.IO;
8+
using System.IO.Pipelines;
89
using System.Linq;
910
using System.Net;
1011
using System.Net.Http;
@@ -65,8 +66,33 @@ protected override async Task<HttpResponseMessage> SendAsync(
6566
var contextBuilder = new HttpContextBuilder(_application, AllowSynchronousIO, PreserveExecutionContext);
6667

6768
var requestContent = request.Content ?? new StreamContent(Stream.Null);
68-
var body = await requestContent.ReadAsStreamAsync();
69-
contextBuilder.Configure(context =>
69+
70+
// Read content from the request HttpContent into a pipe in a background task. This will allow the request
71+
// delegate to start before the request HttpContent is complete. A background task allows duplex streaming scenarios.
72+
contextBuilder.SendRequestStream(async writer =>
73+
{
74+
if (requestContent is StreamContent)
75+
{
76+
// This is odd but required for backwards compat. If StreamContent is passed in then seek to beginning.
77+
// This is safe because StreamContent.ReadAsStreamAsync doesn't block. It will return the inner stream.
78+
var body = await requestContent.ReadAsStreamAsync();
79+
if (body.CanSeek)
80+
{
81+
// This body may have been consumed before, rewind it.
82+
body.Seek(0, SeekOrigin.Begin);
83+
}
84+
85+
await body.CopyToAsync(writer);
86+
}
87+
else
88+
{
89+
await requestContent.CopyToAsync(writer.AsStream());
90+
}
91+
92+
await writer.CompleteAsync();
93+
});
94+
95+
contextBuilder.Configure((context, reader) =>
7096
{
7197
var req = context.Request;
7298

@@ -115,12 +141,7 @@ protected override async Task<HttpResponseMessage> SendAsync(
115141
}
116142
}
117143

118-
if (body.CanSeek)
119-
{
120-
// This body may have been consumed before, rewind it.
121-
body.Seek(0, SeekOrigin.Begin);
122-
}
123-
req.Body = new AsyncStreamWrapper(body, () => contextBuilder.AllowSynchronousIO);
144+
req.Body = new AsyncStreamWrapper(reader.AsStream(), () => contextBuilder.AllowSynchronousIO);
124145
});
125146

126147
var response = new HttpResponseMessage();

src/Hosting/TestHost/src/HttpContextBuilder.cs

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,10 @@ internal class HttpContextBuilder : IHttpBodyControlFeature, IHttpResetFeature
2626
private bool _pipelineFinished;
2727
private bool _returningResponse;
2828
private object _testContext;
29+
private Pipe _requestPipe;
30+
2931
private Action<HttpContext> _responseReadCompleteCallback;
32+
private Task _sendRequestStreamTask;
3033

3134
internal HttpContextBuilder(ApplicationWrapper application, bool allowSynchronousIO, bool preserveExecutionContext)
3235
{
@@ -41,9 +44,11 @@ internal HttpContextBuilder(ApplicationWrapper application, bool allowSynchronou
4144
request.Protocol = "HTTP/1.1";
4245
request.Method = HttpMethods.Get;
4346

44-
var pipe = new Pipe();
45-
_responseReaderStream = new ResponseBodyReaderStream(pipe, ClientInitiatedAbort, () => _responseReadCompleteCallback?.Invoke(_httpContext));
46-
_responsePipeWriter = new ResponseBodyPipeWriter(pipe, ReturnResponseMessageAsync);
47+
_requestPipe = new Pipe();
48+
49+
var responsePipe = new Pipe();
50+
_responseReaderStream = new ResponseBodyReaderStream(responsePipe, ClientInitiatedAbort, () => _responseReadCompleteCallback?.Invoke(_httpContext));
51+
_responsePipeWriter = new ResponseBodyPipeWriter(responsePipe, ReturnResponseMessageAsync);
4752
_responseFeature.Body = new ResponseBodyWriterStream(_responsePipeWriter, () => AllowSynchronousIO);
4853
_responseFeature.BodyWriter = _responsePipeWriter;
4954

@@ -56,14 +61,24 @@ internal HttpContextBuilder(ApplicationWrapper application, bool allowSynchronou
5661

5762
public bool AllowSynchronousIO { get; set; }
5863

59-
internal void Configure(Action<HttpContext> configureContext)
64+
internal void Configure(Action<HttpContext, PipeReader> configureContext)
6065
{
6166
if (configureContext == null)
6267
{
6368
throw new ArgumentNullException(nameof(configureContext));
6469
}
6570

66-
configureContext(_httpContext);
71+
configureContext(_httpContext, _requestPipe.Reader);
72+
}
73+
74+
internal void SendRequestStream(Func<PipeWriter, Task> sendRequestStream)
75+
{
76+
if (sendRequestStream == null)
77+
{
78+
throw new ArgumentNullException(nameof(sendRequestStream));
79+
}
80+
81+
_sendRequestStreamTask = sendRequestStream(_requestPipe.Writer);
6782
}
6883

6984
internal void RegisterResponseReadCompleteCallback(Action<HttpContext> responseReadCompleteCallback)
@@ -92,10 +107,10 @@ async Task RunRequestAsync()
92107
// since we are now inside the Server's execution context. If it happens outside this cont
93108
// it will be lost when we abandon the execution context.
94109
_testContext = _application.CreateContext(_httpContext.Features);
95-
96110
try
97111
{
98112
await _application.ProcessRequestAsync(_testContext);
113+
await CompleteRequestAsync();
99114
await CompleteResponseAsync();
100115
_application.DisposeContext(_testContext, exception: null);
101116
}
@@ -134,8 +149,40 @@ internal void ClientInitiatedAbort()
134149
// We don't want to trigger the token for already completed responses.
135150
_requestLifetimeFeature.Cancel();
136151
}
152+
137153
// Writes will still succeed, the app will only get an error if they check the CT.
138154
_responseReaderStream.Abort(new IOException("The client aborted the request."));
155+
156+
// Cancel any pending request async activity when the client aborts a duplex
157+
// streaming scenario by disposing the HttpResponseMessage.
158+
CancelRequestBody();
159+
}
160+
161+
private async Task CompleteRequestAsync()
162+
{
163+
if (!_requestPipe.Reader.TryRead(out var result) || !result.IsCompleted)
164+
{
165+
// If request is still in progress then abort it.
166+
CancelRequestBody();
167+
}
168+
else
169+
{
170+
// Writer was already completed in send request callback.
171+
await _requestPipe.Reader.CompleteAsync();
172+
}
173+
174+
if (_sendRequestStreamTask != null)
175+
{
176+
try
177+
{
178+
// Ensure duplex request is either completely read or has been aborted.
179+
await _sendRequestStreamTask;
180+
}
181+
catch (OperationCanceledException)
182+
{
183+
// Request was canceled, likely because it wasn't read before the request ended.
184+
}
185+
}
139186
}
140187

141188
internal async Task CompleteResponseAsync()
@@ -192,6 +239,13 @@ internal void Abort(Exception exception)
192239
_responseReaderStream.Abort(exception);
193240
_requestLifetimeFeature.Cancel();
194241
_responseTcs.TrySetException(exception);
242+
CancelRequestBody();
243+
}
244+
245+
private void CancelRequestBody()
246+
{
247+
_requestPipe.Writer.CancelPendingFlush();
248+
_requestPipe.Reader.CancelPendingRead();
195249
}
196250

197251
void IHttpResetFeature.Reset(int errorCode)

src/Hosting/TestHost/src/TestServer.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ public async Task<HttpContext> SendAsync(Action<HttpContext> configureContext, C
138138
}
139139

140140
var builder = new HttpContextBuilder(Application, AllowSynchronousIO, PreserveExecutionContext);
141-
builder.Configure(context =>
141+
builder.Configure((context, reader) =>
142142
{
143143
var request = context.Request;
144144
request.Scheme = BaseAddress.Scheme;
@@ -154,7 +154,7 @@ public async Task<HttpContext> SendAsync(Action<HttpContext> configureContext, C
154154
}
155155
request.PathBase = pathBase;
156156
});
157-
builder.Configure(configureContext);
157+
builder.Configure((context, reader) => configureContext(context));
158158
// TODO: Wrap the request body if any?
159159
return await builder.SendAsync(cancellationToken).ConfigureAwait(false);
160160
}

src/Hosting/TestHost/src/WebSocketClient.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ public async Task<WebSocket> ConnectAsync(Uri uri, CancellationToken cancellatio
5151
{
5252
WebSocketFeature webSocketFeature = null;
5353
var contextBuilder = new HttpContextBuilder(_application, AllowSynchronousIO, PreserveExecutionContext);
54-
contextBuilder.Configure(context =>
54+
contextBuilder.Configure((context, reader) =>
5555
{
5656
var request = context.Request;
5757
var scheme = uri.Scheme;

src/Hosting/TestHost/test/Microsoft.AspNetCore.TestHost.Tests.csproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44
<TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
55
</PropertyGroup>
66

7+
<ItemGroup>
8+
<Compile Include="..\..\..\Shared\SyncPoint\SyncPoint.cs" Link="SyncPoint.cs" />
9+
</ItemGroup>
10+
711
<ItemGroup>
812
<Reference Include="Microsoft.AspNetCore.TestHost" />
913
<Reference Include="Microsoft.Extensions.DiagnosticAdapter" />

0 commit comments

Comments
 (0)