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] Make response streaming opt-out #111680

Merged
merged 9 commits into from
Mar 17, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -244,11 +244,6 @@ await LoopbackServerFactory.CreateServerAsync(async (server, url) =>
var req = new HttpRequestMessage(HttpMethod.Get, url) { Version = UseVersion };
req.Headers.ConnectionClose = connectionClose;

#if TARGET_BROWSER
var WebAssemblyEnableStreamingResponseKey = new HttpRequestOptionsKey<bool>("WebAssemblyEnableStreamingResponse");
req.Options.Set(WebAssemblyEnableStreamingResponseKey, true);
#endif

Task<HttpResponseMessage> getResponse = client.SendAsync(TestAsync, req, HttpCompletionOption.ResponseHeadersRead, cts.Token);
await ValidateClientCancellationAsync(async () =>
{
Expand Down
45 changes: 25 additions & 20 deletions src/libraries/Common/tests/System/Net/Http/HttpClientHandlerTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1005,12 +1005,9 @@ await LoopbackServerFactory.CreateClientAndServerAsync(async uri =>
var request = new HttpRequestMessage(HttpMethod.Get, uri) { Version = UseVersion };
if (PlatformDetection.IsBrowser)
{
if (enableWasmStreaming)
{
#if !NETFRAMEWORK
request.Options.Set(new HttpRequestOptionsKey<bool>("WebAssemblyEnableStreamingResponse"), true);
request.Options.Set(new HttpRequestOptionsKey<bool>("WebAssemblyEnableStreamingResponse"), enableWasmStreaming);
#endif
}
}

using (var client = new HttpMessageInvoker(CreateHttpClientHandler()))
Expand Down Expand Up @@ -1239,7 +1236,7 @@ await LoopbackServerFactory.CreateClientAndServerAsync(async uri =>
// Boolean properties returning correct values
Assert.True(responseStream.CanRead);
Assert.False(responseStream.CanWrite);
Assert.Equal(PlatformDetection.IsBrowser, responseStream.CanSeek);
Assert.False(responseStream.CanSeek);

// Not supported operations
Assert.Throws<NotSupportedException>(() => responseStream.BeginWrite(new byte[1], 0, 1, null, null));
Expand Down Expand Up @@ -1270,11 +1267,14 @@ await LoopbackServerFactory.CreateClientAndServerAsync(async uri =>
Assert.Throws<ArgumentOutOfRangeException>(() => { responseStream.CopyToAsync(Stream.Null, -1, default); });
Assert.Throws<NotSupportedException>(() => { responseStream.CopyToAsync(nonWritableStream, 100, default); });
Assert.Throws<ObjectDisposedException>(() => { responseStream.CopyToAsync(disposedStream, 100, default); });
Assert.Throws<ArgumentNullException>(() => responseStream.Read(null, 0, 100));
Assert.Throws<ArgumentOutOfRangeException>(() => responseStream.Read(new byte[1], -1, 1));
Assert.ThrowsAny<ArgumentException>(() => responseStream.Read(new byte[1], 2, 1));
Assert.Throws<ArgumentOutOfRangeException>(() => responseStream.Read(new byte[1], 0, -1));
Assert.ThrowsAny<ArgumentException>(() => responseStream.Read(new byte[1], 0, 2));
if (PlatformDetection.IsNotBrowser)
{
Assert.Throws<ArgumentNullException>(() => responseStream.Read(null, 0, 100));
Assert.Throws<ArgumentOutOfRangeException>(() => responseStream.Read(new byte[1], -1, 1));
Assert.ThrowsAny<ArgumentException>(() => responseStream.Read(new byte[1], 2, 1));
Assert.Throws<ArgumentOutOfRangeException>(() => responseStream.Read(new byte[1], 0, -1));
Assert.ThrowsAny<ArgumentException>(() => responseStream.Read(new byte[1], 0, 2));
}
Assert.Throws<ArgumentNullException>(() => responseStream.BeginRead(null, 0, 100, null, null));
Assert.Throws<ArgumentOutOfRangeException>(() => responseStream.BeginRead(new byte[1], -1, 1, null, null));
Assert.ThrowsAny<ArgumentException>(() => responseStream.BeginRead(new byte[1], 2, 1, null, null));
Expand All @@ -1284,29 +1284,37 @@ await LoopbackServerFactory.CreateClientAndServerAsync(async uri =>
Assert.Throws<ArgumentNullException>(() => { responseStream.CopyTo(null); });
Assert.Throws<ArgumentNullException>(() => { responseStream.CopyToAsync(null, 100, default); });
Assert.Throws<ArgumentNullException>(() => { responseStream.CopyToAsync(null, 100, default); });
Assert.Throws<ArgumentNullException>(() => { responseStream.Read(null, 0, 100); });
Assert.Throws<ArgumentNullException>(() => { responseStream.ReadAsync(null, 0, 100, default); });
Assert.Throws<ArgumentNullException>(() => { responseStream.BeginRead(null, 0, 100, null, null); });

// Empty reads
var buffer = new byte[1];
Assert.Equal(-1, responseStream.ReadByte());
Assert.Equal(0, await Task.Factory.FromAsync(responseStream.BeginRead, responseStream.EndRead, buffer, 0, 1, null));
if (PlatformDetection.IsNotBrowser)
{
Assert.Equal(-1, responseStream.ReadByte());
Assert.Equal(0, await Task.Factory.FromAsync(responseStream.BeginRead, responseStream.EndRead, buffer, 0, 1, null));
}
#if !NETFRAMEWORK
Assert.Equal(0, await responseStream.ReadAsync(new Memory<byte>(buffer)));
#endif
Assert.Equal(0, await responseStream.ReadAsync(buffer, 0, 1));
if (PlatformDetection.IsNotBrowser)
{
#if !NETFRAMEWORK
Assert.Equal(0, responseStream.Read(new Span<byte>(buffer)));
Assert.Equal(0, responseStream.Read(new Span<byte>(buffer)));
#endif
Assert.Equal(0, responseStream.Read(buffer, 0, 1));
Assert.Equal(0, responseStream.Read(buffer, 0, 1));
}

// Empty copies
var ms = new MemoryStream();
await responseStream.CopyToAsync(ms);
Assert.Equal(0, ms.Length);
responseStream.CopyTo(ms);
Assert.Equal(0, ms.Length);
if (PlatformDetection.IsNotBrowser)
{
responseStream.CopyTo(ms);
Assert.Equal(0, ms.Length);
}
}
}
},
Expand All @@ -1322,9 +1330,6 @@ public async Task ReadAsStreamAsync_StreamingCancellation()
await LoopbackServerFactory.CreateClientAndServerAsync(async uri =>
{
var request = new HttpRequestMessage(HttpMethod.Get, uri) { Version = UseVersion };
#if !NETFRAMEWORK
request.Options.Set(new HttpRequestOptionsKey<bool>("WebAssemblyEnableStreamingResponse"), true);
#endif

var cts = new CancellationTokenSource();
using (var client = new HttpMessageInvoker(CreateHttpClientHandler()))
Expand Down
21 changes: 10 additions & 11 deletions src/libraries/Common/tests/System/Net/Http/ResponseStreamTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ public static IEnumerable<object[]> RemoteServersAndReadModes()
{
for (int i = 0; i < 8; i++)
{
if (PlatformDetection.IsBrowser && i is 0 or 2 or 4 or 5 or 6) continue; // ignore sync reads

yield return new object[] { remoteServer, i };
}
}
Expand Down Expand Up @@ -176,10 +178,13 @@ public async Task GetStreamAsync_ReadZeroBytes_Success(Configuration.Http.Remote
using (HttpClient client = CreateHttpClientForRemoteServer(remoteServer))
using (Stream stream = await client.GetStreamAsync(remoteServer.EchoUri))
{
Assert.Equal(0, stream.Read(new byte[1], 0, 0));
if (PlatformDetection.IsNotBrowser)
{
Assert.Equal(0, stream.Read(new byte[1], 0, 0));
#if !NETFRAMEWORK
Assert.Equal(0, stream.Read(new Span<byte>(new byte[1], 0, 0)));
Assert.Equal(0, stream.Read(new Span<byte>(new byte[1], 0, 0)));
#endif
}
Assert.Equal(0, await stream.ReadAsync(new byte[1], 0, 0));
}
}
Expand All @@ -200,7 +205,7 @@ await client.GetAsync(remoteServer.EchoUri, HttpCompletionOption.ResponseHeaders
cts.Cancel();

// Verify that the task completed.
Assert.True(((IAsyncResult)task).AsyncWaitHandle.WaitOne(new TimeSpan(0, 5, 0)));
Assert.Same(task, await Task.WhenAny(task, Task.Delay(TimeSpan.FromMinutes(5))));
Assert.True(task.IsCompleted, "Task was not yet completed");

// Verify that the task completed successfully or is canceled.
Expand Down Expand Up @@ -327,12 +332,10 @@ public async Task BrowserHttpHandler_StreamingResponseAbort(HttpMethod method, s
public async Task BrowserHttpHandler_Streaming()
{
var WebAssemblyEnableStreamingRequestKey = new HttpRequestOptionsKey<bool>("WebAssemblyEnableStreamingRequest");
var WebAssemblyEnableStreamingResponseKey = new HttpRequestOptionsKey<bool>("WebAssemblyEnableStreamingResponse");

var req = new HttpRequestMessage(HttpMethod.Post, Configuration.Http.RemoteHttp2Server.BaseUri + "echobody.ashx");

req.Options.Set(WebAssemblyEnableStreamingRequestKey, true);
req.Options.Set(WebAssemblyEnableStreamingResponseKey, true);

byte[] body = new byte[1024 * 1024];
Random.Shared.NextBytes(body);
Expand Down Expand Up @@ -578,16 +581,12 @@ public async Task BrowserHttpHandler_StreamingRequest_Http1Fails()
}

[OuterLoop]
[ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsChromium))]
[ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsBrowser))]
public async Task BrowserHttpHandler_StreamingResponseLarge()
{
var WebAssemblyEnableStreamingResponseKey = new HttpRequestOptionsKey<bool>("WebAssemblyEnableStreamingResponse");

var size = 1500 * 1024 * 1024;
var req = new HttpRequestMessage(HttpMethod.Get, Configuration.Http.RemoteSecureHttp11Server.BaseUri + "large.ashx?size=" + size);

req.Options.Set(WebAssemblyEnableStreamingResponseKey, true);

using (HttpClient client = CreateHttpClientForRemoteServer(Configuration.Http.RemoteSecureHttp11Server))
// we need to switch off Response buffering of default ResponseContentRead option
using (HttpResponseMessage response = await client.SendAsync(req, HttpCompletionOption.ResponseHeadersRead))
Expand All @@ -605,7 +604,7 @@ public async Task BrowserHttpHandler_StreamingResponseLarge()
int fetchedCount = 0;
do
{
// with WebAssemblyEnableStreamingResponse option set, we will be using https://developer.mozilla.org/en-US/docs/Web/API/ReadableStreamDefaultReader/read
// we will be using https://developer.mozilla.org/en-US/docs/Web/API/ReadableStreamDefaultReader/read
fetchedCount = await stream.ReadAsync(buffer, 0, buffer.Length);
totalCount += fetchedCount;
} while (fetchedCount != 0);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,9 @@
<method signature="System.Boolean IsGloballyEnabled()" body="stub" value="false" feature="System.Net.Http.EnableActivityPropagation" featurevalue="false" />
</type>
</assembly>
<assembly fullname="System.Net.Http" feature="System.Net.Http.WebAssemblyEnableStreamingResponse" featurevalue="false">
<type fullname="System.Net.Http.BrowserHttpController">
<method signature="System.Boolean get_FeatureEnableStreamingResponse()" body="stub" value="false" />
Copy link
Contributor Author

Choose a reason for hiding this comment

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

What exactly is value="false" / featurevalue="false"? The default value should be true or is this something else?

Copy link
Member

Choose a reason for hiding this comment

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

This means: if you set System.Net.Http.WebAssemblyEnableStreamingResponse to false then ILLink will stub the get_FeatureEnableStreamingResponse to return false instead of reading the config/env.

We don't want to also hardcode true do we ?

Copy link
Member

@javiercn javiercn Mar 11, 2025

Choose a reason for hiding this comment

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

Is the behavior here the same in development as in publish?

We have something similar in Blazor routing and we had to do extra work for the development case
https://github.com/dotnet/aspnetcore/blob/main/src/Components/Components/src/Properties/ILLink.Substitutions.xml#L6-L8 for routing constraints, and it doesn't work in development unless you specifically set the AppContextSwitch manually

https://github.com/dotnet/aspnetcore/blob/f25dc7be397b496cc1c71f8720b2b7d67fb18649/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs#L98-L112

My understanding is that runtimeConfig.json stuff doesn't flow to wasm at the moment.

Copy link
Member

Choose a reason for hiding this comment

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

</type>
</assembly>
</linker>
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using System.Runtime.InteropServices.JavaScript;
using System.Threading;
using System.Threading.Tasks;
using System.Diagnostics.CodeAnalysis;

namespace System.Net.Http
{
Expand Down Expand Up @@ -134,6 +135,9 @@ internal sealed class BrowserHttpController : IDisposable
private static readonly HttpRequestOptionsKey<bool> EnableStreamingResponse = new HttpRequestOptionsKey<bool>("WebAssemblyEnableStreamingResponse");
private static readonly HttpRequestOptionsKey<IDictionary<string, object>> FetchOptions = new HttpRequestOptionsKey<IDictionary<string, object>>("WebAssemblyFetchOptions");

[FeatureSwitchDefinition("System.Net.Http.WasmEnableStreamingResponse")]
internal static bool FeatureEnableStreamingResponse { get; } = AppContextConfigHelper.GetBooleanConfig("System.Net.Http.WasmEnableStreamingResponse", "DOTNET_WASM_ENABLE_STREAMING_RESPONSE", defaultValue: true);

internal readonly JSObject _jsController;
private readonly CancellationTokenRegistration _abortRegistration;
private readonly string[] _optionNames;
Expand All @@ -143,7 +147,7 @@ internal sealed class BrowserHttpController : IDisposable
private readonly string uri;
private readonly CancellationToken _cancellationToken;
private readonly HttpRequestMessage _request;
private bool _isDisposed;
internal bool _isDisposed;

public BrowserHttpController(HttpRequestMessage request, bool? allowAutoRedirect, CancellationToken cancellationToken)
{
Expand Down Expand Up @@ -314,10 +318,14 @@ private HttpResponseMessage ConvertResponse()
responseMessage.SetReasonPhraseWithoutValidation(responseType);
}

bool streamingResponseEnabled = false;
if (BrowserHttpInterop.SupportsStreamingResponse())
bool streamingResponseEnabled = FeatureEnableStreamingResponse;
if (_request.Options.TryGetValue(EnableStreamingResponse, out var reqStreamingResponseEnabled))
{
streamingResponseEnabled = reqStreamingResponseEnabled;
}
if (streamingResponseEnabled && !BrowserHttpInterop.SupportsStreamingResponse())
{
_request.Options.TryGetValue(EnableStreamingResponse, out streamingResponseEnabled);
throw new PlatformNotSupportedException("Streaming response is not supported in this browser.");
}

responseMessage.Content = streamingResponseEnabled
Expand Down Expand Up @@ -361,10 +369,10 @@ public void Dispose()
internal sealed class BrowserHttpWriteStream : Stream
{
private readonly BrowserHttpController _controller; // we don't own it, we don't dispose it from here

public BrowserHttpWriteStream(BrowserHttpController controller)
{
ArgumentNullException.ThrowIfNull(controller);

_controller = controller;
}

Expand Down Expand Up @@ -392,7 +400,7 @@ public override Task WriteAsync(byte[] buffer, int offset, int count, Cancellati

public override bool CanRead => false;
public override bool CanSeek => false;
public override bool CanWrite => true;
public override bool CanWrite => !_controller._isDisposed;

protected override void Dispose(bool disposing)
{
Expand Down Expand Up @@ -506,7 +514,7 @@ protected override void Dispose(bool disposing)

internal sealed class BrowserHttpReadStream : Stream
{
private BrowserHttpController _controller; // we own the object and have to dispose it
private readonly BrowserHttpController _controller; // we own the object and have to dispose it

public BrowserHttpReadStream(BrowserHttpController controller)
{
Expand Down Expand Up @@ -540,7 +548,7 @@ public override Task<int> ReadAsync(byte[] buffer, int offset, int count, Cancel
return ReadAsync(new Memory<byte>(buffer, offset, count), cancellationToken).AsTask();
}

public override bool CanRead => true;
public override bool CanRead => !_controller._isDisposed;
public override bool CanSeek => false;
public override bool CanWrite => false;

Expand Down Expand Up @@ -582,4 +590,24 @@ public override void Write(byte[] buffer, int offset, int count)
}
#endregion
}

internal static class AppContextConfigHelper
{
internal static bool GetBooleanConfig(string switchName, string envVariable, bool defaultValue = false)
{
string? str = Environment.GetEnvironmentVariable(envVariable);
if (str != null)
{
if (str == "1" || str.Equals("true", StringComparison.OrdinalIgnoreCase))
{
return true;
}
if (str == "0" || str.Equals("false", StringComparison.OrdinalIgnoreCase))
{
return false;
}
}
return AppContext.TryGetSwitch(switchName, out bool value) ? value : defaultValue;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,26 @@ namespace System.Net.Http
{
internal static partial class BrowserHttpInterop
{
private static bool? _SupportsStreamingRequest;
private static bool? _SupportsStreamingResponse;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Since this is now cached in .NET we don't need to cache it in JS anymore. Does not hurt though and might still be relevant for MT.

Copy link
Member

Choose a reason for hiding this comment

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

I removed it there, thanks. I believe that the webworker has the same feature set as UI thread.
If not, having static cache in C# side would be wrong.


public static bool SupportsStreamingRequest()
{
_SupportsStreamingRequest ??= SupportsStreamingRequestImpl();
return _SupportsStreamingRequest.Value;
}

public static bool SupportsStreamingResponse()
{
_SupportsStreamingResponse ??= SupportsStreamingResponseImpl();
return _SupportsStreamingResponse.Value;
}

[JSImport("INTERNAL.http_wasm_supports_streaming_request")]
public static partial bool SupportsStreamingRequest();
public static partial bool SupportsStreamingRequestImpl();

[JSImport("INTERNAL.http_wasm_supports_streaming_response")]
public static partial bool SupportsStreamingResponse();
public static partial bool SupportsStreamingResponseImpl();

[JSImport("INTERNAL.http_wasm_create_controller")]
public static partial JSObject CreateController();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
<!-- This test library intentionally references inbox P2Ps as it needs the implementation, instead of the contract.
Suppress the NU1511 warning in the whole project as putting it on a P2P doesn't work: https://github.com/NuGet/Home/issues/14121 -->
<NoWarn>$(NoWarn);NU1511</NoWarn>
<WasmEnableStreamingResponse>false</WasmEnableStreamingResponse>
</PropertyGroup>

<!-- Make debugging easier -->
Expand Down
Loading
Loading