Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[browser] [wasm] Request Streaming upload #91295

Merged
merged 23 commits into from
Sep 1, 2023

Conversation

campersau
Copy link
Contributor

@campersau campersau commented Aug 29, 2023

Uses a ReadableStream as fetch request body and reads chunks from .NET HttpContent stream.
The feature needs to be enabled via HttpRequestOptionsKey<bool>("WebAssemblyEnableStreamingRequest"). A helper could be added to WebAssemblyHttpRequestMessageExtensions.
It is only supported using HTTP/2 or higher and is currently only implemented in Chromium (Chrome / Edge) (see #36634 (comment)).

It currently tries to get the buffer size from the browser via desiredSize which in my testing was always 0. We can also remove this detection and just use the fixed default buffer size.
I also enabled autoAllocateChunkSize which provides us a byobRequest so we can copy directly into the browsers request buffer. Unfortunately WebAssembly/design#1162 is not yet implemented which means we can't do a zero-byte transfer by passing the ArrayBuffer to .NET and avoid any buffers in .NET.

The default buffer size is currently set to 65536. Maybe it should also be made configurable via a HttpRequestOptionsKey?

Exceptions from reading the HttpContent stream will fail the request with the thrown exception.
The special case for StringContent is still preferred and bypasses streaming.

I added some BrowserHttpHandler_* tests to ResponseStreamTest.cs analog to the WebAssemblyEnableStreamingResponse one. Maybe these BrowserHttpHandler_* test should be moved in their own file?
I duplicated the PostAsync_ThrowFromContentCopy_RequsetFails tests to use remote server, as the Http2LoopbackServer does not work in the browser.

Do I need to adjust something regarding FEATURE_WASM_THREADS?

Fixes #36634

@ghost ghost added the community-contribution Indicates that the PR has been added by a community member label Aug 29, 2023
@ghost
Copy link

ghost commented Aug 29, 2023

Tagging subscribers to this area: @dotnet/ncl
See info in area-owners.md if you want to be subscribed.

Issue Details

Uses a ReadableStream as fetch request body and reads chunks from .NET HttpContent stream.
The feature needs to be enabled via HttpRequestOptionsKey<bool>("WebAssemblyEnableStreamingRequest"). A helper could be added to WebAssemblyHttpRequestMessageExtensions.
It is only supported using HTTP/2 or higher and is currently only implemented in Chromium (Chrome / Edge) (see #36634 (comment)).

It currently tries to get the buffer size from the browser via desiredSize which in my testing was always 0. We can also remove this detection and just use the fixed default buffer size.
I also enabled autoAllocateChunkSize which provides us a byobRequest so we can copy directly into the browsers request buffer. Unfortunately WebAssembly/design#1162 is not yet implemented which means we can't do a zero-byte transfer by passing the ArrayBuffer to .NET and avoid any buffers in .NET.
The default buffer size is currently set to 65536. Maybe it should also be made configurable via a HttpRequestOptionsKey?

Exceptions from reading the HttpContent stream will fail the request with the thrown exception.
The special case for StringContent is still prefert and bypasses streaming.

I added some BrowserHttpHandler_* tests to ResponseStreamTest.cs analog to the WebAssemblyEnableStreamingResponse one. Maybe these BrowserHttpHandler_* test should be moved in their own file?
I duplicated the PostAsync_ThrowFromContentCopy_RequsetFails tests to use remote server, as the Http2LoopbackServer does not work in the browser.

Do I need to adjust something regarding FEATURE_WASM_THREADS?

Fixes ##36634

Author: campersau
Assignees: -
Labels:

area-System.Net.Http, community-contribution

Milestone: -

@lewing lewing added this to the 9.0.0 milestone Aug 30, 2023
@lewing lewing requested a review from maraf August 30, 2023 01:14
Copy link
Member

@pavelsavara pavelsavara left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@campersau thanks for doing this! I really appreciate it.

I left few initial comments. Overall it seems sound implementation to me.

Do we have test for both streaming request and streaming response on same call ?
Do we dispose all the JSObject we transmit ?
Could the pull callback become static ? (so that it doesn't allocate for Delegate)

Regarding FEATURE_WASM_THREADS, I think you don't have to do anything about it and it would already work.
I plan to re-write it once more for MT, because I would like to dispatch the calls more asynchronously. Currently it's synchronously blocking the caller, which is not great. And to be able to do that I will have to do more shuffling with the way how pointers/buffers are pinned (on caller stack).
I don't know details yet. But overall it means I will do it for your new code as well.

src/mono/wasm/runtime/http.ts Outdated Show resolved Hide resolved
src/mono/wasm/runtime/http.ts Outdated Show resolved Hide resolved
src/mono/wasm/runtime/http.ts Outdated Show resolved Hide resolved
@campersau
Copy link
Contributor Author

campersau commented Aug 30, 2023

Do we have test for both streaming request and streaming response on same call ?

Unit test which doesn't have all the data ready and not return synchronously would help.

I added a test which does async request and response streaming in 487ef33

Do we dispose all the JSObject we transmit ?

JSObject controller now gets disposed in e2193e6

Could the pull callback become static ? (so that it doesn't allocate for Delegate)

I made it static and moved all the state to a separate class which is send to JS and then back to .NET in 92ae132

@pavelsavara
Copy link
Member

I like it as it is now, @maraf could you please review it ?

@pavelsavara
Copy link
Member

/azp run runtime-wasm

@azure-pipelines
Copy link

Azure Pipelines successfully started running 1 pipeline(s).

Copy link
Member

@maraf maraf left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great job!

src/mono/wasm/runtime/http.ts Show resolved Hide resolved
@pavelsavara
Copy link
Member

I would like to see BrowserHttpHandler_Streaming running on CI, but I can't find it in the list yet.
I would also expect it to fail on NodeJS, so I think it probably didn't run ?

@campersau
Copy link
Contributor Author

campersau commented Sep 1, 2023

I would also expect it to fail on NodeJS, so I think it probably didn't run ?

The BrowserHttpHandler_* tests only run on PlatformDetection.IsBrowser which I think does not include NodeJS, right?
Does TestPlatforms.Browser include NodeJS? Then I guess PostAsync_CancelDuringRequestContentSend_TaskCanceledQuickly could have run and failed, because we don't support streaming there yet?

@pavelsavara
Copy link
Member

pavelsavara commented Sep 1, 2023

PlatformDetection.IsBrowser means "browser OS" and Nodejs is consuming that runtime flavor.

We have plenty of things similar to
[ActiveIssue("https://github.com/dotnet/runtime/issues/86317", typeof(PlatformDetection), nameof(PlatformDetection.IsNodeJS))] which do work on actual browser.

Also [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotNodeJS))]

@campersau
Copy link
Contributor Author

I think it is because of this:

[ConditionalClass(typeof(PlatformDetection), nameof(PlatformDetection.IsBrowserDomSupportedOrNotBrowser))]
public sealed class SocketsHttpHandler_ResponseStreamTest : ResponseStreamTest
{
public SocketsHttpHandler_ResponseStreamTest(ITestOutputHelper output) : base(output) { }
}

If I remove that attribute and run

./dotnet.sh build /t:test /p:TargetOS=browser /p:TargetArchitecture=wasm /p:Scenario=WasmTestOnNodeJS /p:Configuration=Release src/libraries/System.Net.Http/tests/FunctionalTests/System.Net.Http.Functional.Tests.csproj /p:xunitoptions="-method System.Net.Http.Functional.Tests.SocketsHttpHandler_ResponseStreamTest.BrowserHttpHandler_Streaming"

I get failure, but I am not sure why it is this one..

  info: [STRT] System.Net.Http.Functional.Tests.SocketsHttpHandler_ResponseStreamTest.BrowserHttpHandler_Streaming
  fail: [FAIL] System.Net.Http.Functional.Tests.SocketsHttpHandler_ResponseStreamTest.BrowserHttpHandler_Streaming
  info: System.Net.Http.HttpRequestException : Error while copying content to a stream.
  info: ---- System.ObjectDisposedException : Cannot access a closed Stream.
  info: Object name: 'DelegateStream'.
  info:    at System.Net.Http.HttpContent.LoadIntoBufferAsyncCore(Task serializeToStreamTask, MemoryStream tempBuffer)
  info:    at System.Net.Http.HttpContent.<WaitAndReturnAsync>d__82`2[[System.Net.Http.HttpContent, System.Net.Http, Version=8.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a],[System.Byte[], System.Private.CoreLib, Version=8.0.0.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e]].MoveNext()
  info:    at System.Net.Http.BrowserHttpHandler.CallFetch(HttpRequestMessage request, CancellationToken cancellationToken, Nullable`1 allowAutoRedirect)
  info:    at System.Net.Http.BrowserHttpHandler.<SendAsync>g__Impl|55_0(HttpRequestMessage request, CancellationToken cancellationToken, Nullable`1 allowAutoRedirect)
  info:    at System.Net.Http.HttpClient.<SendAsync>g__Core|83_0(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationTokenSource cts, Boolean disposeCts, CancellationTokenSource pendingRequestsCts, CancellationToken originalCancellationToken)
  info:    at System.Net.Http.Functional.Tests.ResponseStreamTest.BrowserHttpHandler_Streaming()
  info: --- End of stack trace from previous location ---
  info: ----- Inner Stack Trace -----
  info:    at System.IO.Stream.CopyToAsync(Stream destination, Int32 bufferSize, CancellationToken cancellationToken)
  info:    at System.IO.Stream.CopyToAsync(Stream destination, CancellationToken cancellationToken)
  info:    at System.Net.Http.StreamToStreamCopy.CopyAsync(Stream source, Stream destination, Int32 bufferSize, Boolean disposeSource, CancellationToken cancellationToken)
  info: --- End of stack trace from previous location ---
  info:    at System.Net.Http.HttpContent.LoadIntoBufferAsyncCore(Task serializeToStreamTask, MemoryStream tempBuffer)

@pavelsavara
Copy link
Member

This is chrome
[PASS] System.Net.Http.Functional.Tests.SocketsHttpHandler_ResponseStreamTest.BrowserHttpHandler_Streaming

@pavelsavara
Copy link
Member

This is NodeJS

It doesn't run the test because

[ConditionalClass(typeof(PlatformDetection), nameof(PlatformDetection.IsBrowserDomSupportedOrNotBrowser))]

Which is OK

@pavelsavara pavelsavara merged commit 9e16f09 into dotnet:main Sep 1, 2023
114 of 116 checks passed
@campersau campersau deleted the browserrequeststreaming branch September 1, 2023 13:17
@guardrex
Copy link

guardrex commented Sep 1, 2023

@pavelsavara ... The issue and PR are marked for 9.0 milestone. I just want to confirm that this will indeed be for 9.0.

@pavelsavara
Copy link
Member

I didn't plan to backport this to Net8, it's too late for that I think.

@lewing your expectations ?

@matthewreiter
Copy link

matthewreiter commented Sep 5, 2023

I noticed that the implementation uses request.Content.ReadAsStreamAsync, which will result in buffering the entire request body when uploading multipart content where one of the parts comes from the InputFile component.

For example:

<InputFile OnChange="OnFileSelect" />
@code {
    void OnFileSelect(InputFileChangeEventArgs e) {
        IBrowserFile file = e.File;
        using var fileStream = file.OpenReadStream(long.MaxValue);
        using var stringContent = new StringContent("Test");
        using var fileContent = new StreamContent(fileStream);

        using var multipartContent = new MultipartFormDataContent
        {
            { stringContent, "metadata" },
            { fileContent, "file", file.Name },
        };

        // And then do a post with multipartContent
}

The issue is that the stream produced by IBrowserFile.OpenReadStream isn't seekable, and MultipartContent.CreateContentReadStreamAsyncCore (indirectly called by ReadAsStreamAsync) will buffer the content in a MemoryStream if any of the streams aren't seekable. In my implementation of streaming requests (which I literally wrote a week and a half ago because I couldn't wait for it to get into the runtime), I resorted to using request.Content.CopyToAsync into a Pipe instead of using request.Content.ReadAsStreamAsync in order to get around this limitation, but a better solution would probably be to change the way MultipartContent.CreateContentReadStreamAsyncCore works to avoid the buffering.

@pavelsavara
Copy link
Member

pavelsavara commented Sep 5, 2023

a better solution would probably be to change the way MultipartContent.CreateContentReadStreamAsyncCore works to avoid the buffering.

@greenEkatherine, @CarnaViire any thought on the feedback above ?
Should we create new issue for that ?
Does the proposed fix make sense for all platforms or we should seek WASM specific fix ?

@campersau
Copy link
Contributor Author

We could change the implementation to use HttpContent.CopyToAsync instead and use a TransformStream in JS which could look something like this: campersau@963f9ff

@ghost ghost locked as resolved and limited conversation to collaborators Oct 6, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
area-System.Net.Http community-contribution Indicates that the PR has been added by a community member
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[browser][wasm] Request Streaming upload via http handler
6 participants