Skip to content

Commit

Permalink
[browser] http streaming request server error (#105709)
Browse files Browse the repository at this point in the history
* wip

* more
  • Loading branch information
pavelsavara authored Aug 6, 2024
1 parent 6b558d9 commit edbb2ba
Show file tree
Hide file tree
Showing 9 changed files with 139 additions and 60 deletions.
2 changes: 2 additions & 0 deletions src/libraries/Common/tests/System/Net/Configuration.Http.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ public static partial class Http
private const string EmptyContentHandler = "EmptyContent.ashx";
private const string RedirectHandler = "Redirect.ashx";
private const string VerifyUploadHandler = "VerifyUpload.ashx";
private const string StatusCodeHandler = "StatusCode.ashx";
private const string DeflateHandler = "Deflate.ashx";
private const string GZipHandler = "GZip.ashx";
private const string RemoteLoopHandler = "RemoteLoop";
Expand All @@ -71,6 +72,7 @@ public static Uri[] GetEchoServerList()
public static readonly Uri RemoteVerifyUploadServer = new Uri("http://" + Host + "/" + VerifyUploadHandler);
public static readonly Uri SecureRemoteVerifyUploadServer = new Uri("https://" + SecureHost + "/" + VerifyUploadHandler);
public static readonly Uri Http2RemoteVerifyUploadServer = new Uri("https://" + Http2Host + "/" + VerifyUploadHandler);
public static readonly Uri Http2RemoteStatusCodeServer = new Uri("https://" + Http2Host + "/" + StatusCodeHandler);

public static readonly Uri RemoteEmptyContentServer = new Uri("http://" + Host + "/" + EmptyContentHandler);
public static readonly Uri RemoteDeflateServer = new Uri("http://" + Host + "/" + DeflateHandler);
Expand Down
75 changes: 61 additions & 14 deletions src/libraries/Common/tests/System/Net/Http/ResponseStreamTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,6 @@ public async Task BrowserHttpHandler_Streaming()
}
}

[OuterLoop]
[ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsChromium))]
public async Task BrowserHttpHandler_StreamingRequest()
{
Expand Down Expand Up @@ -328,8 +327,44 @@ public async Task BrowserHttpHandler_StreamingRequest()
}
}

[ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsChromium))]
public async Task BrowserHttpHandler_StreamingRequest_ServerFail()
{
var WebAssemblyEnableStreamingRequestKey = new HttpRequestOptionsKey<bool>("WebAssemblyEnableStreamingRequest");

var requestUrl = new UriBuilder(Configuration.Http.Http2RemoteStatusCodeServer) { Query = "statuscode=500&statusdescription=test&delay=100" };
var req = new HttpRequestMessage(HttpMethod.Post, requestUrl.Uri);

req.Options.Set(WebAssemblyEnableStreamingRequestKey, true);

int size = 1500 * 1024 * 1024;
int remaining = size;
var content = new MultipartFormDataContent();
content.Add(new StreamContent(new DelegateStream(
canReadFunc: () => true,
readFunc: (buffer, offset, count) => throw new FormatException(),
readAsyncFunc: (buffer, offset, count, cancellationToken) =>
{
if (remaining > 0)
{
int send = Math.Min(remaining, count);
buffer.AsSpan(offset, send).Fill(65);
remaining -= send;
return Task.FromResult(send);
}
return Task.FromResult(0);
})), "test");
req.Content = content;

req.Content.Headers.Add("Content-MD5-Skip", "browser");

using HttpClient client = CreateHttpClientForRemoteServer(Configuration.Http.RemoteHttp2Server);
using HttpResponseMessage response = await client.SendAsync(req);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}


// Duplicate of PostAsync_ThrowFromContentCopy_RequestFails using remote server
[OuterLoop]
[ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsChromium))]
[InlineData(false)]
[InlineData(true)]
Expand Down Expand Up @@ -357,20 +392,19 @@ public async Task BrowserHttpHandler_StreamingRequest_ThrowFromContentCopy_Reque
}

public static TheoryData CancelRequestReadFunctions
=> new TheoryData<bool, Func<Task<int>>>
=> new TheoryData<bool, int, bool>
{
{ false, () => Task.FromResult(0) },
{ true, () => Task.FromResult(0) },
{ false, () => Task.FromResult(1) },
{ true, () => Task.FromResult(1) },
{ false, () => throw new FormatException() },
{ true, () => throw new FormatException() },
{ false, 0, false },
{ true, 0, false },
{ false, 1, false },
{ true, 1, false },
{ false, 0, true },
{ true, 0, true },
};

[OuterLoop]
[ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsChromium))]
[MemberData(nameof(CancelRequestReadFunctions))]
public async Task BrowserHttpHandler_StreamingRequest_CancelRequest(bool cancelAsync, Func<Task<int>> readFunc)
public async Task BrowserHttpHandler_StreamingRequest_CancelRequest(bool cancelAsync, int bytes, bool throwException)
{
var WebAssemblyEnableStreamingRequestKey = new HttpRequestOptionsKey<bool>("WebAssemblyEnableStreamingRequest");

Expand All @@ -397,13 +431,26 @@ public async Task BrowserHttpHandler_StreamingRequest_CancelRequest(bool cancelA
{
readCancelledCount++;
}
return await readFunc();
if (throwException)
{
throw new FormatException("Test");
}
return await Task.FromResult(bytes);
}));

using (HttpClient client = CreateHttpClientForRemoteServer(Configuration.Http.RemoteHttp2Server))
{
TaskCanceledException ex = await Assert.ThrowsAsync<TaskCanceledException>(() => client.SendAsync(req, token));
Assert.Equal(token, ex.CancellationToken);
Exception ex = await Assert.ThrowsAnyAsync<Exception>(() => client.SendAsync(req, token));
if(throwException)
{
Assert.IsType<FormatException>(ex);
Assert.Equal("Test", ex.Message);
}
else
{
var tce = Assert.IsType<TaskCanceledException>(ex);
Assert.Equal(token, tce.CancellationToken);
}
Assert.Equal(1, readNotCancelledCount);
Assert.Equal(0, readCancelledCount);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ public async Task Invoke(HttpContext context)

if (path.Equals(new PathString("/statuscode.ashx")))
{
StatusCodeHandler.Invoke(context);
await StatusCodeHandler.InvokeAsync(context);
return;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,37 @@

using System;
using Microsoft.AspNetCore.Http;
using System.Threading;
using System.Threading.Tasks;
using System.Net.Http;

namespace NetCoreServer
{
public class StatusCodeHandler
{
public static void Invoke(HttpContext context)
public static async Task InvokeAsync(HttpContext context)
{
string statusCodeString = context.Request.Query["statuscode"];
string statusDescription = context.Request.Query["statusdescription"];
string delayString = context.Request.Query["delay"];
try
{
int statusCode = int.Parse(statusCodeString);
int delay = string.IsNullOrWhiteSpace(delayString) ? 0 : int.Parse(delayString);

context.Response.StatusCode = statusCode;
context.Response.SetStatusDescription(
string.IsNullOrWhiteSpace(statusDescription) ? " " : statusDescription);
context.Response.SetStatusDescription(string.IsNullOrWhiteSpace(statusDescription) ? " " : statusDescription);

if (delay > 0)
{
var buffer = new byte[1];
if (context.Request.Method == HttpMethod.Post.Method)
{
await context.Request.Body.ReadExactlyAsync(buffer, CancellationToken.None);
}
await context.Response.StartAsync(CancellationToken.None);
await Task.Delay(delay);
}
}
catch (Exception)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ public BrowserHttpController(HttpRequestMessage request, bool? allowAutoRedirect
if (!_httpController.IsDisposed)
{
BrowserHttpInterop.AbortRequest(_httpController);
BrowserHttpInterop.Abort(_httpController);
}
}, httpController);

Expand Down Expand Up @@ -248,9 +248,16 @@ public async Task<HttpResponseMessage> CallFetch()
{
fetchPromise = BrowserHttpInterop.FetchStream(_jsController, uri, _headerNames, _headerValues, _optionNames, _optionValues);
writeStream = new BrowserHttpWriteStream(this);
await _request.Content.CopyToAsync(writeStream, _cancellationToken).ConfigureAwait(false);
var closePromise = BrowserHttpInterop.TransformStreamClose(_jsController);
await BrowserHttpInterop.CancellationHelper(closePromise, _cancellationToken, _jsController).ConfigureAwait(false);
try
{
await _request.Content.CopyToAsync(writeStream, _cancellationToken).ConfigureAwait(false);
var closePromise = BrowserHttpInterop.TransformStreamClose(_jsController);
await BrowserHttpInterop.CancellationHelper(closePromise, _cancellationToken, _jsController).ConfigureAwait(false);
}
catch(JSException jse) when (jse.Message.Contains("BrowserHttpWriteStream.Rejected", StringComparison.Ordinal))
{
// any error from pushing bytes will also appear in the fetch promise result
}
}
else
{
Expand Down Expand Up @@ -344,7 +351,7 @@ public void Dispose()
{
if (!_jsController.IsDisposed)
{
BrowserHttpInterop.AbortRequest(_jsController);// aborts also response
BrowserHttpInterop.Abort(_jsController);// aborts also response
}
_jsController.Dispose();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,8 @@ internal static partial class BrowserHttpInterop
[JSImport("INTERNAL.http_wasm_create_controller")]
public static partial JSObject CreateController();

[JSImport("INTERNAL.http_wasm_abort_request")]
public static partial void AbortRequest(
JSObject httpController);

[JSImport("INTERNAL.http_wasm_abort_response")]
public static partial void AbortResponse(
JSObject httpController);
[JSImport("INTERNAL.http_wasm_abort")]
public static partial void Abort(JSObject httpController);

[JSImport("INTERNAL.http_wasm_transform_stream_write")]
public static partial Task TransformStreamWrite(
Expand Down Expand Up @@ -143,7 +138,7 @@ public static async Task CancellationHelper(Task promise, CancellationToken canc
CancelablePromise.CancelPromise(_promise);
if (!_jsController.IsDisposed)
{
AbortResponse(_jsController);
Abort(_jsController);
}
}, (promise, jsController)))
{
Expand All @@ -160,6 +155,10 @@ public static async Task CancellationHelper(Task promise, CancellationToken canc
{
throw Http.CancellationHelper.CreateOperationCanceledException(jse, CancellationToken.None);
}
if (jse.Message.Contains("BrowserHttpWriteStream.Rejected", StringComparison.Ordinal))
{
throw; // do not translate
}
Http.CancellationHelper.ThrowIfCancellationRequested(jse, cancellationToken);
throw new HttpRequestException(jse.Message, jse);
}
Expand Down
5 changes: 2 additions & 3 deletions src/mono/browser/runtime/exports-internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import WasmEnableThreads from "consts:wasmEnableThreads";
import { MonoObjectNull, type MonoObject } from "./types/internal";
import cwraps, { profiler_c_functions, threads_c_functions as twraps } from "./cwraps";
import { mono_wasm_send_dbg_command_with_parms, mono_wasm_send_dbg_command, mono_wasm_get_dbg_command_info, mono_wasm_get_details, mono_wasm_release_object, mono_wasm_call_function_on, mono_wasm_debugger_resume, mono_wasm_detach_debugger, mono_wasm_raise_debug_event, mono_wasm_change_debugger_log_level, mono_wasm_debugger_attached } from "./debug";
import { http_wasm_supports_streaming_request, http_wasm_supports_streaming_response, http_wasm_create_controller, http_wasm_abort_request, http_wasm_abort_response, http_wasm_transform_stream_write, http_wasm_transform_stream_close, http_wasm_fetch, http_wasm_fetch_stream, http_wasm_fetch_bytes, http_wasm_get_response_header_names, http_wasm_get_response_header_values, http_wasm_get_response_bytes, http_wasm_get_response_length, http_wasm_get_streamed_response_bytes, http_wasm_get_response_type, http_wasm_get_response_status } from "./http";
import { http_wasm_supports_streaming_request, http_wasm_supports_streaming_response, http_wasm_create_controller, http_wasm_abort, http_wasm_transform_stream_write, http_wasm_transform_stream_close, http_wasm_fetch, http_wasm_fetch_stream, http_wasm_fetch_bytes, http_wasm_get_response_header_names, http_wasm_get_response_header_values, http_wasm_get_response_bytes, http_wasm_get_response_length, http_wasm_get_streamed_response_bytes, http_wasm_get_response_type, http_wasm_get_response_status } from "./http";
import { exportedRuntimeAPI, Module, runtimeHelpers } from "./globals";
import { get_property, set_property, has_property, get_typeof_property, get_global_this, dynamic_import } from "./invoke-js";
import { mono_wasm_stringify_as_error_with_stack } from "./logging";
Expand Down Expand Up @@ -80,8 +80,7 @@ export function export_internal (): any {
http_wasm_create_controller,
http_wasm_get_response_type,
http_wasm_get_response_status,
http_wasm_abort_request,
http_wasm_abort_response,
http_wasm_abort,
http_wasm_transform_stream_write,
http_wasm_transform_stream_close,
http_wasm_fetch,
Expand Down
57 changes: 33 additions & 24 deletions src/mono/browser/runtime/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,30 +72,31 @@ export function http_wasm_create_controller (): HttpController {
return controller;
}

export function http_wasm_abort_request (controller: HttpController): void {
try {
if (controller.streamWriter) {
controller.streamWriter.abort();
function handle_abort_error (promise:Promise<any>) {
promise.catch((err) => {
if (err && err !== "AbortError" && err.name !== "AbortError" ) {
Module.err("Unexpected error: " + err);
}
} catch (err) {
// ignore
}
http_wasm_abort_response(controller);
// otherwise, it's expected
});
}

export function http_wasm_abort_response (controller: HttpController): void {
export function http_wasm_abort (controller: HttpController): void {
if (BuildConfiguration === "Debug") commonAsserts(controller);
try {
controller.isAborted = true;
if (controller.streamReader) {
controller.streamReader.cancel().catch((err) => {
if (err && err.name !== "AbortError") {
Module.err("Error in http_wasm_abort_response: " + err);
}
// otherwise, it's expected
});
if (!controller.isAborted) {
if (controller.streamWriter) {
handle_abort_error(controller.streamWriter.abort());
controller.isAborted = true;
}
if (controller.streamReader) {
handle_abort_error(controller.streamReader.cancel());
controller.isAborted = true;
}
}
if (!controller.isAborted) {
controller.abortController.abort("AbortError");
}
controller.abortController.abort();
} catch (err) {
// ignore
}
Expand All @@ -110,9 +111,12 @@ export function http_wasm_transform_stream_write (controller: HttpController, bu
return wrap_as_cancelable_promise(async () => {
mono_assert(controller.streamWriter, "expected streamWriter");
mono_assert(controller.responsePromise, "expected fetch promise");
// race with fetch because fetch does not cancel the ReadableStream see https://bugs.chromium.org/p/chromium/issues/detail?id=1480250
await Promise.race([controller.streamWriter.ready, controller.responsePromise]);
await Promise.race([controller.streamWriter.write(copy), controller.responsePromise]);
try {
await controller.streamWriter.ready;
await controller.streamWriter.write(copy);
} catch (ex) {
throw new Error("BrowserHttpWriteStream.Rejected");
}
});
}

Expand All @@ -121,16 +125,21 @@ export function http_wasm_transform_stream_close (controller: HttpController): C
return wrap_as_cancelable_promise(async () => {
mono_assert(controller.streamWriter, "expected streamWriter");
mono_assert(controller.responsePromise, "expected fetch promise");
// race with fetch because fetch does not cancel the ReadableStream see https://bugs.chromium.org/p/chromium/issues/detail?id=1480250
await Promise.race([controller.streamWriter.ready, controller.responsePromise]);
await Promise.race([controller.streamWriter.close(), controller.responsePromise]);
try {
await controller.streamWriter.ready;
await controller.streamWriter.close();
} catch (ex) {
throw new Error("BrowserHttpWriteStream.Rejected");
}
});
}

export function http_wasm_fetch_stream (controller: HttpController, url: string, header_names: string[], header_values: string[], option_names: string[], option_values: any[]): ControllablePromise<void> {
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);
const fetch_promise = http_wasm_fetch(controller, url, header_names, header_values, option_names, option_values, transformStream.readable);
return fetch_promise;
}
Expand Down
4 changes: 2 additions & 2 deletions src/mono/browser/runtime/loader/exit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,11 +291,11 @@ function logOnExit (exit_code: number, reason: any) {
}
}
}
function unhandledrejection_handler (event: any) {
function unhandledrejection_handler (event: PromiseRejectionEvent) {
fatal_handler(event, event.reason, "rejection");
}

function error_handler (event: any) {
function error_handler (event: ErrorEvent) {
fatal_handler(event, event.error, "error");
}

Expand Down

0 comments on commit edbb2ba

Please sign in to comment.