Skip to content

Commit 9e16f09

Browse files
[browser] [wasm] Request Streaming upload (#91295)
Co-authored-by: pavelsavara <[email protected]>
1 parent d8b177e commit 9e16f09

File tree

11 files changed

+456
-33
lines changed

11 files changed

+456
-33
lines changed

src/libraries/Common/tests/System/Net/Http/HttpClientHandlerTest.Cancellation.cs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ public HttpClientHandler_Cancellation_Test(ITestOutputHelper output) : base(outp
2828
[Theory]
2929
[InlineData(false, CancellationMode.Token)]
3030
[InlineData(true, CancellationMode.Token)]
31-
[ActiveIssue("https://github.com/dotnet/runtime/issues/36634", TestPlatforms.Browser)] // out of memory
3231
public async Task PostAsync_CancelDuringRequestContentSend_TaskCanceledQuickly(bool chunkedTransfer, CancellationMode mode)
3332
{
3433
if (LoopbackServerFactory.Version >= HttpVersion20.Value && chunkedTransfer)
@@ -42,6 +41,12 @@ public async Task PostAsync_CancelDuringRequestContentSend_TaskCanceledQuickly(b
4241
return;
4342
}
4443

44+
if (PlatformDetection.IsBrowser && LoopbackServerFactory.Version < HttpVersion20.Value)
45+
{
46+
// Browser request streaming is only supported on HTTP/2 or higher
47+
return;
48+
}
49+
4550
var serverRelease = new TaskCompletionSource<bool>();
4651
await LoopbackServerFactory.CreateClientAndServerAsync(async uri =>
4752
{
@@ -58,6 +63,13 @@ await LoopbackServerFactory.CreateClientAndServerAsync(async uri =>
5863
req.Content = new ByteAtATimeContent(int.MaxValue, waitToSend.Task, contentSending, millisecondDelayBetweenBytes: 1);
5964
req.Headers.TransferEncodingChunked = chunkedTransfer;
6065

66+
if (PlatformDetection.IsBrowser)
67+
{
68+
#if !NETFRAMEWORK
69+
req.Options.Set(new HttpRequestOptionsKey<bool>("WebAssemblyEnableStreamingRequest"), true);
70+
#endif
71+
}
72+
6173
Task<HttpResponseMessage> resp = client.SendAsync(TestAsync, req, HttpCompletionOption.ResponseHeadersRead, cts.Token);
6274
waitToSend.SetResult(true);
6375
await Task.WhenAny(contentSending.Task, resp);

src/libraries/Common/tests/System/Net/Http/HttpClientHandlerTest.cs

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1886,16 +1886,30 @@ await connection.ReadRequestHeaderAndSendCustomResponseAsync(
18861886
}
18871887

18881888
[Theory]
1889-
[InlineData(false)]
1890-
[InlineData(true)]
1891-
public async Task PostAsync_ThrowFromContentCopy_RequestFails(bool syncFailure)
1889+
[InlineData(false, false)]
1890+
[InlineData(false, true)]
1891+
[InlineData(true, false)]
1892+
[InlineData(true, true)]
1893+
public async Task PostAsync_ThrowFromContentCopy_RequestFails(bool syncFailure, bool enableWasmStreaming)
18921894
{
18931895
if (UseVersion == HttpVersion30)
18941896
{
18951897
// TODO: Make this version-indepdendent
18961898
return;
18971899
}
18981900

1901+
if (enableWasmStreaming && !PlatformDetection.IsBrowser)
1902+
{
1903+
// enableWasmStreaming makes only sense on Browser platform
1904+
return;
1905+
}
1906+
1907+
if (enableWasmStreaming && PlatformDetection.IsBrowser && UseVersion < HttpVersion20.Value)
1908+
{
1909+
// Browser request streaming is only supported on HTTP/2 or higher
1910+
return;
1911+
}
1912+
18991913
await LoopbackServer.CreateServerAsync(async (server, uri) =>
19001914
{
19011915
Task responseTask = server.AcceptConnectionAsync(async connection =>
@@ -1914,8 +1928,20 @@ await LoopbackServer.CreateServerAsync(async (server, uri) =>
19141928
canReadFunc: () => true,
19151929
readFunc: (buffer, offset, count) => throw error,
19161930
readAsyncFunc: (buffer, offset, count, cancellationToken) => syncFailure ? throw error : Task.Delay(1).ContinueWith<int>(_ => throw error)));
1931+
var request = new HttpRequestMessage(HttpMethod.Post, uri);
1932+
request.Content = content;
1933+
1934+
if (PlatformDetection.IsBrowser)
1935+
{
1936+
if (enableWasmStreaming)
1937+
{
1938+
#if !NETFRAMEWORK
1939+
request.Options.Set(new HttpRequestOptionsKey<bool>("WebAssemblyEnableStreamingRequest"), true);
1940+
#endif
1941+
}
1942+
}
19171943

1918-
Assert.Same(error, await Assert.ThrowsAsync<FormatException>(() => client.PostAsync(uri, content)));
1944+
Assert.Same(error, await Assert.ThrowsAsync<FormatException>(() => client.SendAsync(request)));
19191945
}
19201946
});
19211947
}

src/libraries/Common/tests/System/Net/Http/ResponseStreamTest.cs

Lines changed: 140 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,9 +229,147 @@ await client.GetAsync(remoteServer.EchoUri, HttpCompletionOption.ResponseHeaders
229229
}
230230

231231
#if NETCOREAPP
232-
[OuterLoop]
232+
233233
[ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsBrowser))]
234234
public async Task BrowserHttpHandler_Streaming()
235+
{
236+
var WebAssemblyEnableStreamingRequestKey = new HttpRequestOptionsKey<bool>("WebAssemblyEnableStreamingRequest");
237+
var WebAssemblyEnableStreamingResponseKey = new HttpRequestOptionsKey<bool>("WebAssemblyEnableStreamingResponse");
238+
239+
var req = new HttpRequestMessage(HttpMethod.Post, Configuration.Http.RemoteHttp2Server.BaseUri + "echobody.ashx");
240+
241+
req.Options.Set(WebAssemblyEnableStreamingRequestKey, true);
242+
req.Options.Set(WebAssemblyEnableStreamingResponseKey, true);
243+
244+
byte[] body = new byte[1024 * 1024];
245+
Random.Shared.NextBytes(body);
246+
247+
int readOffset = 0;
248+
req.Content = new StreamContent(new DelegateStream(
249+
readAsyncFunc: async (buffer, offset, count, cancellationToken) =>
250+
{
251+
await Task.Delay(1);
252+
if (readOffset < body.Length)
253+
{
254+
int send = Math.Min(body.Length - readOffset, count);
255+
body.AsSpan(readOffset, send).CopyTo(buffer.AsSpan(offset, send));
256+
readOffset += send;
257+
return send;
258+
}
259+
return 0;
260+
}));
261+
262+
using (HttpClient client = CreateHttpClientForRemoteServer(Configuration.Http.RemoteHttp2Server))
263+
// we need to switch off Response buffering of default ResponseContentRead option
264+
using (HttpResponseMessage response = await client.SendAsync(req, HttpCompletionOption.ResponseHeadersRead))
265+
{
266+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
267+
// Streaming requests can't set Content-Length
268+
Assert.False(response.Headers.Contains("X-HttpRequest-Headers-ContentLength"));
269+
// Streaming response uses StreamContent
270+
Assert.Equal(typeof(StreamContent), response.Content.GetType());
271+
272+
var stream = await response.Content.ReadAsStreamAsync();
273+
Assert.Equal("ReadOnlyStream", stream.GetType().Name);
274+
var buffer = new byte[1024 * 1024];
275+
int totalCount = 0;
276+
int fetchedCount = 0;
277+
do
278+
{
279+
fetchedCount = await stream.ReadAsync(buffer, 0, buffer.Length);
280+
Assert.True(body.AsSpan(totalCount, fetchedCount).SequenceEqual(buffer.AsSpan(0, fetchedCount)));
281+
totalCount += fetchedCount;
282+
} while (fetchedCount != 0);
283+
Assert.Equal(body.Length, totalCount);
284+
}
285+
}
286+
287+
[OuterLoop]
288+
[ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsBrowser))]
289+
[InlineData(true)]
290+
[InlineData(false)]
291+
public async Task BrowserHttpHandler_StreamingRequest(bool useStringContent)
292+
{
293+
var WebAssemblyEnableStreamingRequestKey = new HttpRequestOptionsKey<bool>("WebAssemblyEnableStreamingRequest");
294+
295+
var req = new HttpRequestMessage(HttpMethod.Post, Configuration.Http.Http2RemoteVerifyUploadServer);
296+
297+
req.Options.Set(WebAssemblyEnableStreamingRequestKey, true);
298+
299+
int size;
300+
if (useStringContent)
301+
{
302+
string bodyContent = "Hello World";
303+
size = bodyContent.Length;
304+
req.Content = new StringContent(bodyContent);
305+
}
306+
else
307+
{
308+
size = 1500 * 1024 * 1024;
309+
int remaining = size;
310+
req.Content = new StreamContent(new DelegateStream(
311+
readAsyncFunc: (buffer, offset, count, cancellationToken) =>
312+
{
313+
if (remaining > 0)
314+
{
315+
int send = Math.Min(remaining, count);
316+
buffer.AsSpan(offset, send).Fill(65);
317+
remaining -= send;
318+
return Task.FromResult(send);
319+
}
320+
return Task.FromResult(0);
321+
}));
322+
}
323+
324+
req.Content.Headers.Add("Content-MD5-Skip", "browser");
325+
326+
using (HttpClient client = CreateHttpClientForRemoteServer(Configuration.Http.RemoteHttp2Server))
327+
using (HttpResponseMessage response = await client.SendAsync(req))
328+
{
329+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
330+
Assert.Equal(size.ToString(), Assert.Single(response.Headers.GetValues("X-HttpRequest-Body-Length")));
331+
// Streaming requests can't set Content-Length
332+
Assert.Equal(useStringContent, response.Headers.Contains("X-HttpRequest-Headers-ContentLength"));
333+
if (useStringContent)
334+
{
335+
Assert.Equal(size.ToString(), Assert.Single(response.Headers.GetValues("X-HttpRequest-Headers-ContentLength")));
336+
}
337+
}
338+
}
339+
340+
// Duplicate of PostAsync_ThrowFromContentCopy_RequestFails using remote server
341+
[OuterLoop]
342+
[ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsBrowser))]
343+
[InlineData(false)]
344+
[InlineData(true)]
345+
public async Task BrowserHttpHandler_StreamingRequest_ThrowFromContentCopy_RequestFails(bool syncFailure)
346+
{
347+
var WebAssemblyEnableStreamingRequestKey = new HttpRequestOptionsKey<bool>("WebAssemblyEnableStreamingRequest");
348+
349+
var req = new HttpRequestMessage(HttpMethod.Post, Configuration.Http.Http2RemoteEchoServer);
350+
351+
req.Options.Set(WebAssemblyEnableStreamingRequestKey, true);
352+
353+
Exception error = new FormatException();
354+
var content = new StreamContent(new DelegateStream(
355+
canSeekFunc: () => true,
356+
lengthFunc: () => 12345678,
357+
positionGetFunc: () => 0,
358+
canReadFunc: () => true,
359+
readFunc: (buffer, offset, count) => throw error,
360+
readAsyncFunc: (buffer, offset, count, cancellationToken) => syncFailure ? throw error : Task.Delay(1).ContinueWith<int>(_ => throw error)));
361+
362+
req.Content = content;
363+
364+
using (HttpClient client = CreateHttpClientForRemoteServer(Configuration.Http.RemoteHttp2Server))
365+
{
366+
Assert.Same(error, await Assert.ThrowsAsync<FormatException>(() => client.SendAsync(req)));
367+
}
368+
}
369+
370+
[OuterLoop]
371+
[ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsBrowser))]
372+
public async Task BrowserHttpHandler_StreamingResponse()
235373
{
236374
var WebAssemblyEnableStreamingResponseKey = new HttpRequestOptionsKey<bool>("WebAssemblyEnableStreamingResponse");
237375

@@ -244,6 +382,7 @@ public async Task BrowserHttpHandler_Streaming()
244382
// we need to switch off Response buffering of default ResponseContentRead option
245383
using (HttpResponseMessage response = await client.SendAsync(req, HttpCompletionOption.ResponseHeadersRead))
246384
{
385+
// Streaming response uses StreamContent
247386
Assert.Equal(typeof(StreamContent), response.Content.GetType());
248387

249388
Assert.Equal("application/octet-stream", response.Content.Headers.ContentType.MediaType);

src/libraries/Common/tests/System/Net/Prerequisites/NetCoreServer/GenericHandler.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,11 @@ public async Task Invoke(HttpContext context)
8888
await LargeResponseHandler.InvokeAsync(context);
8989
return;
9090
}
91+
if (path.Equals(new PathString("/echobody.ashx")))
92+
{
93+
await EchoBodyHandler.InvokeAsync(context);
94+
return;
95+
}
9196

9297
// Default handling.
9398
await EchoHandler.InvokeAsync(context);
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System;
5+
using System.Threading.Tasks;
6+
using Microsoft.AspNetCore.Http;
7+
using Microsoft.AspNetCore.Http.Features;
8+
9+
namespace NetCoreServer
10+
{
11+
public class EchoBodyHandler
12+
{
13+
public static async Task InvokeAsync(HttpContext context)
14+
{
15+
context.Features.Get<IHttpMaxRequestBodySizeFeature>().MaxRequestBodySize = null;
16+
17+
// Report back original request method verb.
18+
context.Response.Headers["X-HttpRequest-Method"] = context.Request.Method;
19+
20+
// Report back original entity-body related request headers.
21+
string contentLength = context.Request.Headers["Content-Length"];
22+
if (!string.IsNullOrEmpty(contentLength))
23+
{
24+
context.Response.Headers["X-HttpRequest-Headers-ContentLength"] = contentLength;
25+
}
26+
27+
string transferEncoding = context.Request.Headers["Transfer-Encoding"];
28+
if (!string.IsNullOrEmpty(transferEncoding))
29+
{
30+
context.Response.Headers["X-HttpRequest-Headers-TransferEncoding"] = transferEncoding;
31+
}
32+
33+
context.Response.StatusCode = 200;
34+
context.Response.ContentType = context.Request.ContentType;
35+
await context.Request.Body.CopyToAsync(context.Response.Body);
36+
}
37+
}
38+
}

src/libraries/Common/tests/System/Net/Prerequisites/NetCoreServer/Handlers/VerifyUploadHandler.cs

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,16 @@
66
using System.Security.Cryptography;
77
using System.Threading.Tasks;
88
using Microsoft.AspNetCore.Http;
9+
using Microsoft.AspNetCore.Http.Features;
910

1011
namespace NetCoreServer
1112
{
1213
public class VerifyUploadHandler
1314
{
1415
public static async Task InvokeAsync(HttpContext context)
1516
{
17+
context.Features.Get<IHttpMaxRequestBodySizeFeature>().MaxRequestBodySize = null;
18+
1619
// Report back original request method verb.
1720
context.Response.Headers["X-HttpRequest-Method"] = context.Request.Method;
1821

@@ -29,12 +32,15 @@ public static async Task InvokeAsync(HttpContext context)
2932
context.Response.Headers["X-HttpRequest-Headers-TransferEncoding"] = transferEncoding;
3033
}
3134

32-
// Get request body.
33-
byte[] requestBodyBytes = await ReadAllRequestBytesAsync(context);
35+
// Compute MD5 hash of received request body.
36+
(byte[] md5Bytes, int bodyLength) = await ComputeMD5HashRequestBodyAsync(context);
37+
38+
// Report back the actual body length.
39+
context.Response.Headers["X-HttpRequest-Body-Length"] = bodyLength.ToString();
3440

35-
// Skip MD5 checksum for empty request body
41+
// Skip MD5 checksum for empty request body
3642
// or for requests which opt to skip it due to [ActiveIssue("https://github.com/dotnet/runtime/issues/37669", TestPlatforms.Browser)]
37-
if (requestBodyBytes.Length == 0 || !string.IsNullOrEmpty(context.Request.Headers["Content-MD5-Skip"]))
43+
if (bodyLength == 0 || !string.IsNullOrEmpty(context.Request.Headers["Content-MD5-Skip"]))
3844
{
3945
context.Response.StatusCode = 200;
4046
return;
@@ -49,13 +55,7 @@ public static async Task InvokeAsync(HttpContext context)
4955
return;
5056
}
5157

52-
// Compute MD5 hash of received request body.
53-
string actualHash;
54-
using (MD5 md5 = MD5.Create())
55-
{
56-
byte[] hash = md5.ComputeHash(requestBodyBytes);
57-
actualHash = Convert.ToBase64String(hash);
58-
}
58+
string actualHash = Convert.ToBase64String(md5Bytes);
5959

6060
if (expectedHash == actualHash)
6161
{
@@ -66,21 +66,22 @@ public static async Task InvokeAsync(HttpContext context)
6666
context.Response.StatusCode = 400;
6767
context.Response.SetStatusDescription("Received request body fails MD5 checksum");
6868
}
69-
7069
}
7170

72-
private static async Task<byte[]> ReadAllRequestBytesAsync(HttpContext context)
71+
private static async Task<(byte[] MD5Hash, int BodyLength)> ComputeMD5HashRequestBodyAsync(HttpContext context)
7372
{
7473
Stream requestStream = context.Request.Body;
7574
byte[] buffer = new byte[16 * 1024];
76-
using (MemoryStream ms = new MemoryStream())
75+
using (MD5 md5 = MD5.Create())
7776
{
78-
int read;
77+
int read, size = 0;
7978
while ((read = await requestStream.ReadAsync(buffer, 0, buffer.Length)) > 0)
8079
{
81-
ms.Write(buffer, 0, read);
80+
size += read;
81+
md5.TransformBlock(buffer, 0, read, buffer, 0);
8282
}
83-
return ms.ToArray();
83+
md5.TransformFinalBlock(buffer, 0, read);
84+
return (md5.Hash, size);
8485
}
8586
}
8687
}

src/libraries/Common/tests/System/Net/Prerequisites/NetCoreServer/NetCoreServer.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
<ItemGroup>
2727
<Compile Include="Handlers\DeflateHandler.cs" />
2828
<Compile Include="Handlers\EchoHandler.cs" />
29+
<Compile Include="Handlers\EchoBodyHandler.cs" />
2930
<Compile Include="Handlers\EchoWebSocketHandler.cs" />
3031
<Compile Include="Handlers\EchoWebSocketHeadersHandler.cs" />
3132
<Compile Include="Handlers\EmptyContentHandler.cs" />

0 commit comments

Comments
 (0)