Skip to content

[release/9.0] [browser][http] mute JS exceptions about network errors + HEAD verb #113261

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

Merged
merged 14 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
95 changes: 94 additions & 1 deletion src/libraries/Common/tests/System/Net/Http/ResponseStreamTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,99 @@ await client.GetAsync(remoteServer.EchoUri, HttpCompletionOption.ResponseHeaders

#if NET

public static IEnumerable<object[]> HttpMethods => new object[][]
{
new [] { HttpMethod.Get },
new [] { HttpMethod.Head },
new [] { HttpMethod.Post },
new [] { HttpMethod.Put },
new [] { HttpMethod.Delete },
new [] { HttpMethod.Options },
new [] { HttpMethod.Patch },
};

public static IEnumerable<object[]> HttpMethodsAndAbort => new object[][]
{
new object[] { HttpMethod.Get, "abortBeforeHeaders" },
new object[] { HttpMethod.Head , "abortBeforeHeaders"},
new object[] { HttpMethod.Post , "abortBeforeHeaders"},
new object[] { HttpMethod.Put , "abortBeforeHeaders"},
new object[] { HttpMethod.Delete , "abortBeforeHeaders"},
new object[] { HttpMethod.Options , "abortBeforeHeaders"},
new object[] { HttpMethod.Patch , "abortBeforeHeaders"},

new object[] { HttpMethod.Get, "abortAfterHeaders" },
new object[] { HttpMethod.Post , "abortAfterHeaders"},
new object[] { HttpMethod.Put , "abortAfterHeaders"},
new object[] { HttpMethod.Delete , "abortAfterHeaders"},
new object[] { HttpMethod.Options , "abortAfterHeaders"},
new object[] { HttpMethod.Patch , "abortAfterHeaders"},

new object[] { HttpMethod.Get, "abortDuringBody" },
new object[] { HttpMethod.Post , "abortDuringBody"},
new object[] { HttpMethod.Put , "abortDuringBody"},
new object[] { HttpMethod.Delete , "abortDuringBody"},
new object[] { HttpMethod.Options , "abortDuringBody"},
new object[] { HttpMethod.Patch , "abortDuringBody"},

};

[MemberData(nameof(HttpMethods))]
[ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsBrowser))]
public async Task BrowserHttpHandler_StreamingResponse(HttpMethod method)
{
var WebAssemblyEnableStreamingResponseKey = new HttpRequestOptionsKey<bool>("WebAssemblyEnableStreamingResponse");

var req = new HttpRequestMessage(method, Configuration.Http.RemoteHttp11Server.BaseUri + "echo.ashx");
req.Options.Set(WebAssemblyEnableStreamingResponseKey, true);

if (method == HttpMethod.Post)
{
req.Content = new StringContent("hello world");
}

using (HttpClient client = CreateHttpClientForRemoteServer(Configuration.Http.RemoteHttp11Server))
// we need to switch off Response buffering of default ResponseContentRead option
using (HttpResponseMessage response = await client.SendAsync(req, HttpCompletionOption.ResponseHeadersRead))
{
using var content = response.Content;
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(typeof(StreamContent), content.GetType());
Assert.NotEqual(0, content.Headers.ContentLength);
if (method != HttpMethod.Head)
{
var data = await content.ReadAsByteArrayAsync();
Assert.NotEqual(0, data.Length);
}
}
}

[MemberData(nameof(HttpMethodsAndAbort))]
[ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsBrowser))]
public async Task BrowserHttpHandler_StreamingResponseAbort(HttpMethod method, string abort)
{
var WebAssemblyEnableStreamingResponseKey = new HttpRequestOptionsKey<bool>("WebAssemblyEnableStreamingResponse");

var req = new HttpRequestMessage(method, Configuration.Http.RemoteHttp11Server.BaseUri + "echo.ashx?" + abort + "=true");
req.Options.Set(WebAssemblyEnableStreamingResponseKey, true);

if (method == HttpMethod.Post || method == HttpMethod.Put || method == HttpMethod.Patch)
{
req.Content = new StringContent("hello world");
}

using HttpClient client = CreateHttpClientForRemoteServer(Configuration.Http.RemoteHttp11Server);
if (abort == "abortDuringBody")
{
using var res = await client.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
await Assert.ThrowsAsync<HttpRequestException>(() => res.Content.ReadAsByteArrayAsync());
}
else
{
await Assert.ThrowsAsync<HttpRequestException>(() => client.SendAsync(req, HttpCompletionOption.ResponseHeadersRead));
}
}

[ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsChromium))]
public async Task BrowserHttpHandler_Streaming()
{
Expand Down Expand Up @@ -486,7 +579,7 @@ public async Task BrowserHttpHandler_StreamingRequest_Http1Fails()

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,33 +22,37 @@ public static async Task InvokeAsync(HttpContext context)
return;
}

// Add original request method verb as a custom response header.
context.Response.Headers["X-HttpRequest-Method"] = context.Request.Method;

// Echo back JSON encoded payload.
RequestInformation info = await RequestInformation.CreateAsync(context.Request);
string echoJson = info.SerializeToJson();

byte[] bytes = Encoding.UTF8.GetBytes(echoJson);

var qs = context.Request.QueryString.HasValue ? context.Request.QueryString.Value : "";
var delay = 0;
if (context.Request.QueryString.HasValue)
if (qs.Contains("delay1sec"))
{
if (context.Request.QueryString.Value.Contains("delay1sec"))
{
delay = 1000;
}
else if (context.Request.QueryString.Value.Contains("delay10sec"))
{
delay = 10000;
}
delay = 1000;
}
else if (qs.Contains("delay10sec"))
{
delay = 10000;
}

if (qs.Contains("abortBeforeHeaders"))
{
context.Abort();
return;
}

if (delay > 0)
{
context.Features.Get<IHttpResponseBodyFeature>().DisableBuffering();
}

// Echo back JSON encoded payload.
RequestInformation info = await RequestInformation.CreateAsync(context.Request);
string echoJson = info.SerializeToJson();
byte[] bytes = Encoding.UTF8.GetBytes(echoJson);

// Add original request method verb as a custom response header.
context.Response.Headers["X-HttpRequest-Method"] = context.Request.Method;

// Compute MD5 hash so that clients can verify the received data.
using (MD5 md5 = MD5.Create())
{
Expand All @@ -60,11 +64,32 @@ public static async Task InvokeAsync(HttpContext context)
context.Response.ContentLength = bytes.Length;
}

if (delay > 0)
await context.Response.StartAsync(CancellationToken.None);

if (qs.Contains("abortAfterHeaders"))
{
await Task.Delay(10);
context.Abort();
return;
}

if (HttpMethods.IsHead(context.Request.Method))
{
return;
}

if (delay > 0 || qs.Contains("abortDuringBody"))
{
await context.Response.StartAsync(CancellationToken.None);
await context.Response.Body.WriteAsync(bytes, 0, 10);
await context.Response.Body.FlushAsync();
if (qs.Contains("abortDuringBody"))
{
await context.Response.Body.FlushAsync();
await Task.Delay(10);
context.Abort();
return;
}

await Task.Delay(delay);
await context.Response.Body.WriteAsync(bytes, 10, bytes.Length-10);
await context.Response.Body.FlushAsync();
Expand Down
40 changes: 24 additions & 16 deletions src/mono/browser/runtime/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@
import BuildConfiguration from "consts:configuration";

import { wrap_as_cancelable_promise } from "./cancelable-promise";
import { ENVIRONMENT_IS_NODE, Module, loaderHelpers, mono_assert } from "./globals";
import { ENVIRONMENT_IS_NODE, loaderHelpers, mono_assert } from "./globals";
import { assert_js_interop } from "./invoke-js";
import { MemoryViewType, Span } from "./marshal";
import type { VoidPtr } from "./types/emscripten";
import { ControllablePromise } from "./types/internal";
import { mono_log_debug } from "./logging";


function verifyEnvironment () {
Expand Down Expand Up @@ -72,12 +73,11 @@ export function http_wasm_create_controller (): HttpController {
return controller;
}

function handle_abort_error (promise:Promise<any>) {
function mute_unhandledrejection (promise:Promise<any>) {
promise.catch((err) => {
if (err && err !== "AbortError" && err.name !== "AbortError" ) {
Module.err("Unexpected error: " + err);
mono_log_debug("http muted: " + err);
}
// otherwise, it's expected
});
}

Expand All @@ -86,15 +86,15 @@ export function http_wasm_abort (controller: HttpController): void {
try {
if (!controller.isAborted) {
if (controller.streamWriter) {
handle_abort_error(controller.streamWriter.abort());
mute_unhandledrejection(controller.streamWriter.abort());
controller.isAborted = true;
}
if (controller.streamReader) {
handle_abort_error(controller.streamReader.cancel());
mute_unhandledrejection(controller.streamReader.cancel());
controller.isAborted = true;
}
}
if (!controller.isAborted) {
if (!controller.isAborted && !controller.abortController.signal.aborted) {
controller.abortController.abort("AbortError");
}
} catch (err) {
Expand Down Expand Up @@ -138,8 +138,8 @@ export function http_wasm_fetch_stream (controller: HttpController, url: string,
if (BuildConfiguration === "Debug") commonAsserts(controller);
const transformStream = new TransformStream<Uint8Array, Uint8Array>();
controller.streamWriter = transformStream.writable.getWriter();
handle_abort_error(controller.streamWriter.closed);
handle_abort_error(controller.streamWriter.ready);
mute_unhandledrejection(controller.streamWriter.closed);
mute_unhandledrejection(controller.streamWriter.ready);
const fetch_promise = http_wasm_fetch(controller, url, header_names, header_values, option_names, option_values, transformStream.readable);
return fetch_promise;
}
Expand Down Expand Up @@ -177,16 +177,18 @@ export function http_wasm_fetch (controller: HttpController, url: string, header
}
// make the fetch cancellable
controller.responsePromise = wrap_as_cancelable_promise(() => {
return loaderHelpers.fetch_like(url, options);
return loaderHelpers.fetch_like(url, options).then((res: Response) => {
controller.response = res;
return null;// drop the response from the promise chain
});
});
// avoid processing headers if the fetch is canceled
controller.responsePromise.then((res: Response) => {
controller.response = res;
controller.responsePromise.then(() => {
mono_assert(controller.response, "expected response");
controller.responseHeaderNames = [];
controller.responseHeaderValues = [];
if (res.headers && (<any>res.headers).entries) {
const entries: Iterable<string[]> = (<any>res.headers).entries();

if (controller.response.headers && (<any>controller.response.headers).entries) {
const entries: Iterable<string[]> = (<any>controller.response.headers).entries();
for (const pair of entries) {
controller.responseHeaderNames.push(pair[0]);
controller.responseHeaderValues.push(pair[1]);
Expand Down Expand Up @@ -250,9 +252,15 @@ export function http_wasm_get_streamed_response_bytes (controller: HttpControlle
// the bufferPtr is pinned by the caller
const view = new Span(bufferPtr, bufferLength, MemoryViewType.Byte);
return wrap_as_cancelable_promise(async () => {
await controller.responsePromise;
mono_assert(controller.response, "expected response");
if (!controller.response.body) {
// in FF when the verb is HEAD, the body is null
return 0;
}
if (!controller.streamReader) {
controller.streamReader = controller.response.body!.getReader();
controller.streamReader = controller.response.body.getReader();
mute_unhandledrejection(controller.streamReader.closed);
}
if (!controller.currentStreamReaderChunk || controller.currentBufferOffset === undefined) {
controller.currentStreamReaderChunk = await controller.streamReader.read();
Expand Down
Loading