From e8f185864cbdff89a045c0cdd67fd52e818db2fd Mon Sep 17 00:00:00 2001 From: pavelsavara Date: Tue, 20 Jan 2026 20:42:02 +0100 Subject: [PATCH 1/5] wip --- .../System.Net.Http.Functional.Tests.csproj | 2 +- .../BrowserWebSockets/BrowserInterop.cs | 6 +- .../System.Net.WebSockets.Client.Tests.csproj | 1 + ...me.InteropServices.JavaScript.Tests.csproj | 5 +- src/libraries/tests.proj | 2 + src/mono/browser/runtime/web-socket.ts | 8 +- .../libs/Common/JavaScript/CMakeLists.txt | 4 + .../Common/JavaScript/cross-module/index.ts | 2 + .../Common/JavaScript/types/ems-ambient.ts | 1 + .../libs/Common/JavaScript/types/exchange.ts | 6 + .../System.Native.Browser/diagnostics/exit.ts | 1 + .../libSystem.Native.Browser.footer.js | 2 +- .../System.Native.Browser/native/index.ts | 4 +- .../native/scheduling.ts | 4 + .../interop/http.ts | 299 +++++++++++ .../interop/index.ts | 42 +- .../interop/marshal-to-cs.ts | 1 + .../interop/marshal.ts | 2 + .../interop/queue.ts | 70 +++ .../interop/throttling.ts | 61 +++ .../interop/utils.ts | 11 + .../interop/web-socket.ts | 503 ++++++++++++++++++ 22 files changed, 1023 insertions(+), 14 deletions(-) create mode 100644 src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/http.ts create mode 100644 src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/queue.ts create mode 100644 src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/throttling.ts create mode 100644 src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/web-socket.ts diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/System.Net.Http.Functional.Tests.csproj b/src/libraries/System.Net.Http/tests/FunctionalTests/System.Net.Http.Functional.Tests.csproj index a41940d68ce8dc..05b98c0d2035c8 100644 --- a/src/libraries/System.Net.Http/tests/FunctionalTests/System.Net.Http.Functional.Tests.csproj +++ b/src/libraries/System.Net.Http/tests/FunctionalTests/System.Net.Http.Functional.Tests.csproj @@ -27,7 +27,7 @@ $(DefineConstants);TARGET_BROWSER 01:15:00 - true + true diff --git a/src/libraries/System.Net.WebSockets.Client/src/System/Net/WebSockets/BrowserWebSockets/BrowserInterop.cs b/src/libraries/System.Net.WebSockets.Client/src/System/Net/WebSockets/BrowserWebSockets/BrowserInterop.cs index a131a76aeb1fff..1c7a5716b82aa0 100644 --- a/src/libraries/System.Net.WebSockets.Client/src/System/Net/WebSockets/BrowserWebSockets/BrowserInterop.cs +++ b/src/libraries/System.Net.WebSockets.Client/src/System/Net/WebSockets/BrowserWebSockets/BrowserInterop.cs @@ -26,12 +26,12 @@ internal static partial class BrowserInterop { return null; } - if (!webSocket.HasProperty("close_status")) + if (!webSocket.HasProperty("closeStatus")) { return null; } - int status = webSocket.GetPropertyAsInt32("close_status"); + int status = webSocket.GetPropertyAsInt32("closeStatus"); return (WebSocketCloseStatus)status; } @@ -42,7 +42,7 @@ internal static partial class BrowserInterop return null; } - string? description = webSocket.GetPropertyAsString("close_status_description"); + string? description = webSocket.GetPropertyAsString("closeStatusDescription"); return description; } diff --git a/src/libraries/System.Net.WebSockets.Client/tests/System.Net.WebSockets.Client.Tests.csproj b/src/libraries/System.Net.WebSockets.Client/tests/System.Net.WebSockets.Client.Tests.csproj index f82a72f18c7457..83cf148c6a9fd6 100644 --- a/src/libraries/System.Net.WebSockets.Client/tests/System.Net.WebSockets.Client.Tests.csproj +++ b/src/libraries/System.Net.WebSockets.Client/tests/System.Net.WebSockets.Client.Tests.csproj @@ -17,6 +17,7 @@ $(TestArchiveTestsRoot)$(OSPlatformConfig)/ $(DefineConstants);TARGET_BROWSER 1 + true diff --git a/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System.Runtime.InteropServices.JavaScript.Tests.csproj b/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System.Runtime.InteropServices.JavaScript.Tests.csproj index c73e7d9be128c5..3d87bce766315f 100644 --- a/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System.Runtime.InteropServices.JavaScript.Tests.csproj +++ b/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System.Runtime.InteropServices.JavaScript.Tests.csproj @@ -14,7 +14,7 @@ true 1 - true + true $(NoWarn);IL2103;IL2025;IL2111;IL2122 true - + diff --git a/src/libraries/tests.proj b/src/libraries/tests.proj index ca448d883b66fc..f1ba00d3ff41c7 100644 --- a/src/libraries/tests.proj +++ b/src/libraries/tests.proj @@ -543,6 +543,8 @@ + + diff --git a/src/mono/browser/runtime/web-socket.ts b/src/mono/browser/runtime/web-socket.ts index 1f29488d36a8f1..f15bd0668f2135 100644 --- a/src/mono/browser/runtime/web-socket.ts +++ b/src/mono/browser/runtime/web-socket.ts @@ -104,8 +104,8 @@ export function ws_wasm_create (uri: string, sub_protocols: string[] | null, rec forceThreadMemoryViewRefresh(); ws[wasm_ws_close_received] = true; - ws["close_status"] = ev.code; - ws["close_status_description"] = ev.reason; + ws["closeStatus"] = ev.code; + ws["closeStatusDescription"] = ev.reason; if (ws[wasm_ws_pending_open_promise_used]) { open_promise_control.reject(new Error(ev.reason)); @@ -492,8 +492,8 @@ type WebSocketExtension = WebSocket & { [wasm_ws_pending_send_buffer_offset]: number [wasm_ws_pending_send_buffer_type]: number [wasm_ws_pending_send_buffer]: Uint8Array | null - ["close_status"]: number | undefined - ["close_status_description"]: string | undefined + ["closeStatus"]: number | undefined + ["closeStatusDescription"]: string | undefined dispose(): void } diff --git a/src/native/libs/Common/JavaScript/CMakeLists.txt b/src/native/libs/Common/JavaScript/CMakeLists.txt index 138908110164aa..a6c6e673c48602 100644 --- a/src/native/libs/Common/JavaScript/CMakeLists.txt +++ b/src/native/libs/Common/JavaScript/CMakeLists.txt @@ -80,6 +80,7 @@ set(ROLLUP_TS_SOURCES "${CLR_SRC_NATIVE_DIR}/libs/System.Runtime.InteropServices.JavaScript.Native/interop/cancelable-promise.ts" "${CLR_SRC_NATIVE_DIR}/libs/System.Runtime.InteropServices.JavaScript.Native/interop/cross-module.ts" "${CLR_SRC_NATIVE_DIR}/libs/System.Runtime.InteropServices.JavaScript.Native/interop/gc-handles.ts" + "${CLR_SRC_NATIVE_DIR}/libs/System.Runtime.InteropServices.JavaScript.Native/interop/http.ts" "${CLR_SRC_NATIVE_DIR}/libs/System.Runtime.InteropServices.JavaScript.Native/interop/index.ts" "${CLR_SRC_NATIVE_DIR}/libs/System.Runtime.InteropServices.JavaScript.Native/interop/invoke-cs.ts" "${CLR_SRC_NATIVE_DIR}/libs/System.Runtime.InteropServices.JavaScript.Native/interop/invoke-js.ts" @@ -90,9 +91,12 @@ set(ROLLUP_TS_SOURCES "${CLR_SRC_NATIVE_DIR}/libs/System.Runtime.InteropServices.JavaScript.Native/interop/marshal.ts" "${CLR_SRC_NATIVE_DIR}/libs/System.Runtime.InteropServices.JavaScript.Native/interop/marshaled-types.ts" "${CLR_SRC_NATIVE_DIR}/libs/System.Runtime.InteropServices.JavaScript.Native/interop/per-module.ts" + "${CLR_SRC_NATIVE_DIR}/libs/System.Runtime.InteropServices.JavaScript.Native/interop/queue.ts" + "${CLR_SRC_NATIVE_DIR}/libs/System.Runtime.InteropServices.JavaScript.Native/interop/throttling.ts" "${CLR_SRC_NATIVE_DIR}/libs/System.Runtime.InteropServices.JavaScript.Native/interop/types.ts" "${CLR_SRC_NATIVE_DIR}/libs/System.Runtime.InteropServices.JavaScript.Native/interop/utils.ts" "${CLR_SRC_NATIVE_DIR}/libs/System.Runtime.InteropServices.JavaScript.Native/interop/weak-ref.ts" + "${CLR_SRC_NATIVE_DIR}/libs/System.Runtime.InteropServices.JavaScript.Native/interop/web-socket.ts" "${CLR_SRC_NATIVE_DIR}/libs/System.Runtime.InteropServices.JavaScript.Native/native/index.ts" "${CLR_SRC_NATIVE_DIR}/libs/System.Runtime.InteropServices.JavaScript.Native/types.ts" ) diff --git a/src/native/libs/Common/JavaScript/cross-module/index.ts b/src/native/libs/Common/JavaScript/cross-module/index.ts index 14e434cb635e0b..dd1259505f3eca 100644 --- a/src/native/libs/Common/JavaScript/cross-module/index.ts +++ b/src/native/libs/Common/JavaScript/cross-module/index.ts @@ -107,6 +107,7 @@ export function dotnetUpdateInternalsSubscriber() { cancelPromise: table[4], invokeJSFunction: table[5], forceDisposeProxies: table[6], + stopThrottlingPrevention: table[7], }; Object.assign(runtime, runtimerLocal); } @@ -169,6 +170,7 @@ export function dotnetUpdateInternalsSubscriber() { // keep in sync with nativeBrowserExportsToTable() function nativeBrowserExportsFromTable(table: NativeBrowserExportsTable, interop: NativeBrowserExports): void { const interopLocal: NativeBrowserExports = { + runBackgroundTicks: table[0], }; Object.assign(interop, interopLocal); } diff --git a/src/native/libs/Common/JavaScript/types/ems-ambient.ts b/src/native/libs/Common/JavaScript/types/ems-ambient.ts index e4ff4f2a05a637..1b7992c98627b9 100644 --- a/src/native/libs/Common/JavaScript/types/ems-ambient.ts +++ b/src/native/libs/Common/JavaScript/types/ems-ambient.ts @@ -37,6 +37,7 @@ export type EmsAmbientSymbolsType = EmscriptenModuleInternal & { _SystemInteropJS_ReleaseJSOwnedObjectByGCHandle: (args: JSMarshalerArguments) => void; _SystemInteropJS_BindAssemblyExports: (args: JSMarshalerArguments) => void; _SystemInteropJS_CallJSExport: (methodHandle: CSFnHandle, args: JSMarshalerArguments) => void; + _runBackgroundTicks: () => void; FS: { createPath: (parent: string, path: string, canRead?: boolean, canWrite?: boolean) => string; diff --git a/src/native/libs/Common/JavaScript/types/exchange.ts b/src/native/libs/Common/JavaScript/types/exchange.ts index f7e003f518a2c2..3c8f19f69d355c 100644 --- a/src/native/libs/Common/JavaScript/types/exchange.ts +++ b/src/native/libs/Common/JavaScript/types/exchange.ts @@ -15,8 +15,10 @@ import type { bindJSImportST, invokeJSFunction, invokeJSImportST } from "../../. import type { forceDisposeProxies, releaseCSOwnedObject } from "../../../System.Runtime.InteropServices.JavaScript.Native/interop/gc-handles"; import type { resolveOrRejectPromise } from "../../../System.Runtime.InteropServices.JavaScript.Native/interop/marshal-to-js"; import type { cancelPromise } from "../../../System.Runtime.InteropServices.JavaScript.Native/interop/cancelable-promise"; +import type { stopThrottlingPrevention } from "../../../System.Runtime.InteropServices.JavaScript.Native/interop/throttling"; import type { symbolicateStackTrace } from "../../../System.Native.Browser/diagnostics/symbolicate"; +import type { runBackgroundTicks } from "../../../System.Native.Browser/native/scheduling"; import type { EmsAmbientSymbolsType } from "../types"; export type RuntimeExports = { @@ -27,6 +29,7 @@ export type RuntimeExports = { cancelPromise: typeof cancelPromise, invokeJSFunction: typeof invokeJSFunction, forceDisposeProxies: typeof forceDisposeProxies, + stopThrottlingPrevention: typeof stopThrottlingPrevention, } export type RuntimeExportsTable = [ @@ -37,6 +40,7 @@ export type RuntimeExportsTable = [ typeof cancelPromise, typeof invokeJSFunction, typeof forceDisposeProxies, + typeof stopThrottlingPrevention, ] export type LoggerType = { @@ -120,9 +124,11 @@ export type InteropJavaScriptExportsTable = [ ] export type NativeBrowserExports = { + runBackgroundTicks: typeof runBackgroundTicks, } export type NativeBrowserExportsTable = [ + typeof runBackgroundTicks, ] export type BrowserUtilsExports = { diff --git a/src/native/libs/System.Native.Browser/diagnostics/exit.ts b/src/native/libs/System.Native.Browser/diagnostics/exit.ts index abd538dc542699..6f1e1fc32c283f 100644 --- a/src/native/libs/System.Native.Browser/diagnostics/exit.ts +++ b/src/native/libs/System.Native.Browser/diagnostics/exit.ts @@ -25,6 +25,7 @@ function onExit(exitCode: number, reason: any, silent: boolean): boolean { if (!loaderConfig) { return true; } + dotnetRuntimeExports.stopThrottlingPrevention(); if (exitCode === 0 && loaderConfig.interopCleanupOnExit) { dotnetRuntimeExports.forceDisposeProxies(true, true); } diff --git a/src/native/libs/System.Native.Browser/libSystem.Native.Browser.footer.js b/src/native/libs/System.Native.Browser/libSystem.Native.Browser.footer.js index c7e48d369fea58..48a68c0b064882 100644 --- a/src/native/libs/System.Native.Browser/libSystem.Native.Browser.footer.js +++ b/src/native/libs/System.Native.Browser/libSystem.Native.Browser.footer.js @@ -19,7 +19,7 @@ const exports = {}; libNativeBrowser(exports); - let commonDeps = ["$BROWSER_UTILS", "SystemJS_ExecuteTimerCallback", "SystemJS_ExecuteBackgroundJobCallback"]; + let commonDeps = ["$BROWSER_UTILS", "SystemJS_ExecuteTimerCallback", "SystemJS_ExecuteBackgroundJobCallback", "runBackgroundTicks"]; const lib = { $DOTNET: { selfInitialize: () => { diff --git a/src/native/libs/System.Native.Browser/native/index.ts b/src/native/libs/System.Native.Browser/native/index.ts index fee0bb46e8d6d2..3e53e96c18a73b 100644 --- a/src/native/libs/System.Native.Browser/native/index.ts +++ b/src/native/libs/System.Native.Browser/native/index.ts @@ -10,7 +10,7 @@ import GitHash from "consts:gitHash"; export { SystemJS_RandomBytes } from "./crypto"; export { SystemJS_GetLocaleInfo } from "./globalization-locale"; export { SystemJS_RejectMainPromise, SystemJS_ResolveMainPromise, SystemJS_ConsoleClear } from "./main"; -export { SystemJS_ScheduleTimer, SystemJS_ScheduleBackgroundJob } from "./scheduling"; +export { SystemJS_ScheduleTimer, SystemJS_ScheduleBackgroundJob, runBackgroundTicks } from "./scheduling"; export const gitHash = GitHash; export function dotnetInitializeModule(internals: InternalExchange): void { @@ -24,6 +24,7 @@ export function dotnetInitializeModule(internals: InternalExchange): void { } internals[InternalExchangeIndex.NativeBrowserExportsTable] = nativeBrowserExportsToTable({ + runBackgroundTicks: _ems_._runBackgroundTicks, }); _ems_.dotnetUpdateInternals(internals, _ems_.dotnetUpdateInternalsSubscriber); @@ -31,6 +32,7 @@ export function dotnetInitializeModule(internals: InternalExchange): void { function nativeBrowserExportsToTable(map: NativeBrowserExports): NativeBrowserExportsTable { // keep in sync with nativeBrowserExportsFromTable() return [ + map.runBackgroundTicks, ]; } } diff --git a/src/native/libs/System.Native.Browser/native/scheduling.ts b/src/native/libs/System.Native.Browser/native/scheduling.ts index e195e928818811..ea8c1e370237df 100644 --- a/src/native/libs/System.Native.Browser/native/scheduling.ts +++ b/src/native/libs/System.Native.Browser/native/scheduling.ts @@ -31,3 +31,7 @@ export function SystemJS_ScheduleBackgroundJob(): void { } } +export function runBackgroundTicks(): void { + _ems_._SystemJS_ExecuteTimerCallback(); + _ems_._SystemJS_ExecuteBackgroundJobCallback(); +} diff --git a/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/http.ts b/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/http.ts new file mode 100644 index 00000000000000..e1d8665351df05 --- /dev/null +++ b/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/http.ts @@ -0,0 +1,299 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +import BuildConfiguration from "consts:configuration"; + +import type { VoidPtr, ControllablePromise } from "./types"; + +import { wrapAsCancelablePromise } from "./cancelable-promise"; +import { ENVIRONMENT_IS_NODE } from "./per-module"; +import { assertJsInterop } from "./utils"; +import { MemoryViewType, Span } from "./marshaled-types"; +import { dotnetLogger, dotnetAssert } from "./cross-module"; + + +function verifyEnvironment() { + if (typeof globalThis.fetch !== "function" || typeof globalThis.AbortController !== "function") { + const message = ENVIRONMENT_IS_NODE + ? "Please install `node-fetch` and `node-abort-controller` npm packages to enable HTTP client support. See also https://aka.ms/dotnet-wasm-features" + : "This browser doesn't support fetch API. Please use a modern browser. See also https://aka.ms/dotnet-wasm-features"; + throw new Error(message); + } +} + +function commonAsserts(controller: HttpController) { + assertJsInterop(); + dotnetAssert.check(controller, "expected controller"); +} + +export function httpSupportsStreamingRequest(): boolean { + // Detecting streaming request support works like this: + // If the browser doesn't support a particular body type, it calls toString() on the object and uses the result as the body. + // So, if the browser doesn't support request streams, the request body becomes the string "[object ReadableStream]". + // When a string is used as a body, it conveniently sets the Content-Type header to text/plain;charset=UTF-8. + // So, if that header is set, then we know the browser doesn't support streams in request objects, and we can exit early. + // Safari does support streams in request objects, but doesn't allow them to be used with fetch, so the duplex option is tested, which Safari doesn't currently support. + // See https://developer.chrome.com/articles/fetch-streaming-requests/ + if (typeof Request !== "undefined" && "body" in Request.prototype && typeof ReadableStream === "function" && typeof TransformStream === "function") { + let duplexAccessed = false; + const hasContentType = new Request("", { + body: new ReadableStream(), + method: "POST", + get duplex() { + duplexAccessed = true; + return "half"; + }, + } as RequestInit /* https://github.com/microsoft/TypeScript-DOM-lib-generator/issues/1483 */).headers.has("Content-Type"); + return duplexAccessed && !hasContentType; + } + return false; +} + +export function httpSupportsStreamingResponse(): boolean { + return typeof Response !== "undefined" && "body" in Response.prototype && typeof ReadableStream === "function"; +} + +export function httpCreateController(): HttpController { + verifyEnvironment(); + assertJsInterop(); + const controller: HttpController = { + abortController: new AbortController() + }; + return controller; +} + +function muteUnhandledRejection(promise: Promise) { + promise.catch((err) => { + if (err && err !== "AbortError" && err.name !== "AbortError") { + dotnetLogger.debug("http muted: " + err); + } + }); +} + +export function httpAbort(controller: HttpController): void { + if (BuildConfiguration === "Debug") commonAsserts(controller); + try { + if (!controller.isAborted) { + if (controller.streamWriter) { + muteUnhandledRejection(controller.streamWriter.abort()); + controller.isAborted = true; + } + if (controller.streamReader) { + muteUnhandledRejection(controller.streamReader.cancel()); + controller.isAborted = true; + } + } + if (!controller.isAborted && !controller.abortController.signal.aborted) { + controller.abortController.abort("AbortError"); + } + } catch (err) { + // ignore + } +} + +export function httpTransformStreamWrite(controller: HttpController, bufferPtr: VoidPtr, bufferLength: number): ControllablePromise { + if (BuildConfiguration === "Debug") commonAsserts(controller); + dotnetAssert.check(bufferLength > 0, "expected bufferLength > 0"); + // the bufferPtr is pinned by the caller + const view = new Span(bufferPtr, bufferLength, MemoryViewType.Byte); + const copy = view.slice() as Uint8Array; + return wrapAsCancelablePromise(async () => { + dotnetAssert.check(controller.streamWriter, "expected streamWriter"); + dotnetAssert.check(controller.responsePromise, "expected fetch promise"); + try { + await controller.streamWriter.ready; + await controller.streamWriter.write(copy); + } catch (ex) { + throw new Error("BrowserHttpWriteStream.Rejected"); + } + }); +} + +export function httpTransformStreamClose(controller: HttpController): ControllablePromise { + dotnetAssert.check(controller, "expected controller"); + return wrapAsCancelablePromise(async () => { + dotnetAssert.check(controller.streamWriter, "expected streamWriter"); + dotnetAssert.check(controller.responsePromise, "expected fetch promise"); + try { + await controller.streamWriter.ready; + await controller.streamWriter.close(); + } catch (ex) { + throw new Error("BrowserHttpWriteStream.Rejected"); + } + }); +} + +export function httpFetchStream(controller: HttpController, url: string, headerNames: string[], headerValues: string[], optionNames: string[], optionValues: any[]): ControllablePromise { + if (BuildConfiguration === "Debug") commonAsserts(controller); + const transformStream = new TransformStream(); + controller.streamWriter = transformStream.writable.getWriter(); + muteUnhandledRejection(controller.streamWriter.closed); + muteUnhandledRejection(controller.streamWriter.ready); + const fetchPromise = httpFetch(controller, url, headerNames, headerValues, optionNames, optionValues, transformStream.readable); + return fetchPromise; +} + +export function httpFetchBytes(controller: HttpController, url: string, headerNames: string[], headerValues: string[], optionNames: string[], optionValues: any[], bodyPtr: VoidPtr, bodyLength: number): ControllablePromise { + if (BuildConfiguration === "Debug") commonAsserts(controller); + // the bodyPtr is pinned by the caller + const view = new Span(bodyPtr, bodyLength, MemoryViewType.Byte); + const copy = view.slice() as Uint8Array; + return httpFetch(controller, url, headerNames, headerValues, optionNames, optionValues, copy); +} + +export function httpFetch(controller: HttpController, url: string, headerNames: string[], headerValues: string[], optionNames: string[], optionValues: any[], body: Uint8Array | ReadableStream | null): ControllablePromise { + if (BuildConfiguration === "Debug") commonAsserts(controller); + verifyEnvironment(); + assertJsInterop(); + dotnetAssert.check(url && typeof url === "string", "expected url string"); + dotnetAssert.check(headerNames && headerValues && Array.isArray(headerNames) && Array.isArray(headerValues) && headerNames.length === headerValues.length, "expected headerNames and headerValues arrays"); + dotnetAssert.check(optionNames && optionValues && Array.isArray(optionNames) && Array.isArray(optionValues) && optionNames.length === optionValues.length, "expected headerNames and headerValues arrays"); + + const headers = new Headers(); + for (let i = 0; i < headerNames.length; i++) { + headers.append(headerNames[i], headerValues[i]); + } + const options: any = { + body, + headers, + signal: controller.abortController.signal + }; + if (typeof ReadableStream !== "undefined" && body instanceof ReadableStream) { + options.duplex = "half"; + } + for (let i = 0; i < optionNames.length; i++) { + options[optionNames[i]] = optionValues[i]; + } + controller.responsePromise = wrapAsCancelablePromise(() => { + return globalThis.fetch(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(() => { + dotnetAssert.check(controller.response, "expected response"); + controller.responseHeaderNames = []; + controller.responseHeaderValues = []; + if (controller.response.headers && (controller.response.headers).entries) { + const entries: Iterable = (controller.response.headers).entries(); + for (const pair of entries) { + controller.responseHeaderNames.push(pair[0]); + controller.responseHeaderValues.push(pair[1]); + } + } + }).catch(() => { + // ignore + }); + return controller.responsePromise; +} + +export function httpGetResponseType(controller: HttpController): string | undefined { + if (BuildConfiguration === "Debug") commonAsserts(controller); + return controller.response?.type; +} + +export function httpGetResponseStatus(controller: HttpController): number { + if (BuildConfiguration === "Debug") commonAsserts(controller); + return controller.response?.status ?? 0; +} + + +export function httpGetResponseHeaderNames(controller: HttpController): string[] { + if (BuildConfiguration === "Debug") commonAsserts(controller); + dotnetAssert.check(controller.responseHeaderNames, "expected responseHeaderNames"); + return controller.responseHeaderNames; +} + +export function httpGetResponseHeaderValues(controller: HttpController): string[] { + if (BuildConfiguration === "Debug") commonAsserts(controller); + dotnetAssert.check(controller.responseHeaderValues, "expected responseHeaderValues"); + return controller.responseHeaderValues; +} + +export function httpGetResponseLength(controller: HttpController): ControllablePromise { + if (BuildConfiguration === "Debug") commonAsserts(controller); + return wrapAsCancelablePromise(async () => { + const buffer = await controller.response!.arrayBuffer(); + controller.responseBuffer = buffer; + controller.currentBufferOffset = 0; + return buffer.byteLength; + }); +} + +export function httpGetResponseBytes(controller: HttpController, view: Span): number { + dotnetAssert.check(controller, "expected controller"); + dotnetAssert.check(controller.responseBuffer, "expected resoved arrayBuffer"); + dotnetAssert.check(controller.currentBufferOffset != undefined, "expected currentBufferOffset"); + if (controller.currentBufferOffset == controller.responseBuffer!.byteLength) { + return 0; + } + const sourceView = new Uint8Array(controller.responseBuffer!, controller.currentBufferOffset); + view.set(sourceView, 0); + const bytesRead = Math.min(view.byteLength, sourceView.byteLength); + controller.currentBufferOffset += bytesRead; + return bytesRead; +} + +export function httpGetStreamedResponseBytes(controller: HttpController, bufferPtr: VoidPtr, bufferLength: number): ControllablePromise { + if (BuildConfiguration === "Debug") commonAsserts(controller); + // the bufferPtr is pinned by the caller + const view = new Span(bufferPtr, bufferLength, MemoryViewType.Byte); + return wrapAsCancelablePromise(async () => { + await controller.responsePromise; + dotnetAssert.check(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(); + muteUnhandledRejection(controller.streamReader.closed); + } + if (!controller.currentStreamReaderChunk || controller.currentBufferOffset === undefined) { + controller.currentStreamReaderChunk = await controller.streamReader.read(); + controller.currentBufferOffset = 0; + } + if (controller.currentStreamReaderChunk.done) { + if (controller.isAborted) { + throw new Error("OperationCanceledException"); + } + return 0; + } + + const remainingSource = controller.currentStreamReaderChunk.value.byteLength - controller.currentBufferOffset; + dotnetAssert.check(remainingSource > 0, "expected remainingSource to be greater than 0"); + + const bytesCopied = Math.min(remainingSource, view.byteLength); + const sourceView = controller.currentStreamReaderChunk.value.subarray(controller.currentBufferOffset, controller.currentBufferOffset + bytesCopied); + view.set(sourceView, 0); + controller.currentBufferOffset += bytesCopied; + if (remainingSource == bytesCopied) { + controller.currentStreamReaderChunk = undefined; + } + + return bytesCopied; + }); +} + +interface HttpController { + abortController: AbortController + isAborted?: boolean + + // streaming request + streamReader?: ReadableStreamDefaultReader + + // response + responsePromise?: ControllablePromise + response?: Response + responseHeaderNames?: string[]; + responseHeaderValues?: string[]; + currentBufferOffset?: number + + // non-streaming response + responseBuffer?: ArrayBuffer + + // streaming response + streamWriter?: WritableStreamDefaultWriter + currentStreamReaderChunk?: ReadableStreamReadResult +} diff --git a/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/index.ts b/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/index.ts index 05940f27b53e87..f618a8179aca3f 100644 --- a/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/index.ts +++ b/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/index.ts @@ -7,7 +7,10 @@ import { InternalExchangeIndex } from "../types"; import GitHash from "consts:gitHash"; import { dotnetUpdateInternals, dotnetUpdateInternalsSubscriber } from "./cross-module"; -import { bindJSImportST, dynamicImport, getDotnetInstance, getGlobalThis, getProperty, getTypeOfProperty, hasProperty, invokeJSFunction, invokeJSImportST, setModuleImports, setProperty } from "./invoke-js"; +import { + bindJSImportST, dynamicImport, getDotnetInstance, getGlobalThis, getProperty, getTypeOfProperty, hasProperty, + invokeJSFunction, invokeJSImportST, setModuleImports, setProperty +} from "./invoke-js"; import { bindCsFunction, getAssemblyExports } from "./invoke-cs"; import { initializeMarshalersToJs, resolveOrRejectPromise } from "./marshal-to-js"; import { initializeMarshalersToCs } from "./marshal-to-cs"; @@ -15,6 +18,14 @@ import { forceDisposeProxies, releaseCSOwnedObject } from "./gc-handles"; import { cancelPromise } from "./cancelable-promise"; import { loadLazyAssembly, loadSatelliteAssemblies } from "./lazy"; import { jsInteropState } from "./marshal"; +import { initializeScheduling, stopThrottlingPrevention } from "./throttling"; +import { wsAbort, wsClose, wsCreate, wsGetState, wsOpen, wsReceive, wsSend } from "./web-socket"; +import { + httpSupportsStreamingRequest, httpSupportsStreamingResponse, httpCreateController, httpGetResponseType, + httpGetResponseStatus, httpAbort, httpTransformStreamWrite, httpTransformStreamClose, httpFetch, + httpFetchStream, httpFetchBytes, httpGetResponseHeaderNames, httpGetResponseHeaderValues, httpGetResponseBytes, + httpGetResponseLength, httpGetStreamedResponseBytes, +} from "./http"; export function dotnetInitializeModule(internals: InternalExchange): void { if (!Array.isArray(internals)) throw new Error("Expected internals to be an array"); @@ -42,6 +53,32 @@ export function dotnetInitializeModule(internals: InternalExchange): void { loadSatelliteAssemblies, loadLazyAssembly, + // WebSocket + wsCreate, + wsOpen, + wsSend, + wsReceive, + wsClose, + wsAbort, + wsGetState, + + // HTTP + httpSupportsStreamingRequest, + httpSupportsStreamingResponse, + httpCreateController, + httpGetResponseType, + httpGetResponseStatus, + httpAbort, + httpTransformStreamWrite, + httpTransformStreamClose, + httpFetch, + httpFetchStream, + httpFetchBytes, + httpGetResponseHeaderNames, + httpGetResponseHeaderValues, + httpGetResponseBytes, + httpGetResponseLength, + httpGetStreamedResponseBytes, }); internals[InternalExchangeIndex.RuntimeExportsTable] = runtimeExportsToTable({ @@ -52,11 +89,13 @@ export function dotnetInitializeModule(internals: InternalExchange): void { cancelPromise, invokeJSFunction, forceDisposeProxies, + stopThrottlingPrevention, }); dotnetUpdateInternals(internals, dotnetUpdateInternalsSubscriber); initializeMarshalersToJs(); initializeMarshalersToCs(); + initializeScheduling(); jsInteropState.isInitialized = true; jsInteropState.enablePerfMeasure = globalThis.performance && typeof globalThis.performance.measure === "function"; @@ -70,6 +109,7 @@ export function dotnetInitializeModule(internals: InternalExchange): void { map.cancelPromise, map.invokeJSFunction, map.forceDisposeProxies, + map.stopThrottlingPrevention, ]; } } diff --git a/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/marshal-to-cs.ts b/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/marshal-to-cs.ts index e78309cad9f0e4..78581e20e52082 100644 --- a/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/marshal-to-cs.ts +++ b/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/marshal-to-cs.ts @@ -280,6 +280,7 @@ export function marshalTaskToCs(arg: JSMarshalerArgument, value: Promise, _ const handleIsPreallocated = getArgType(arg) == MarshalerType.TaskPreCreated; if (value === null || value === undefined) { setArgType(arg, MarshalerType.None); + return; } dotnetAssert.check(isThenable(value), "Value is not a Promise"); diff --git a/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/marshal.ts b/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/marshal.ts index 7d50e3aa090eab..eff31c4d305274 100644 --- a/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/marshal.ts +++ b/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/marshal.ts @@ -11,6 +11,8 @@ export const jsInteropState = { proxyGCHandle: undefined as GCHandle | undefined, cspPolicy: false, isInitialized: false, + isChromium: false, + isFirefox: false, enablePerfMeasure: false, managedThreadTID: 0 as any as PThreadPtr, }; diff --git a/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/queue.ts b/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/queue.ts new file mode 100644 index 00000000000000..74f507671ff98c --- /dev/null +++ b/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/queue.ts @@ -0,0 +1,70 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +export class Queue { + // amortized time, By Kate Morley http://code.iamkate.com/ under CC0 1.0 + private queue: T[]; + private offset: number; + + constructor() { + this.queue = []; + this.offset = 0; + } + // initialise the queue and offset + + // Returns the length of the queue. + getLength(): number { + return (this.queue.length - this.offset); + } + + // Returns true if the queue is empty, and false otherwise. + isEmpty(): boolean { + return (this.queue.length == 0); + } + + /* Enqueues the specified item. The parameter is: + * + * item - the item to enqueue + */ + enqueue(item: T): void { + this.queue.push(item); + } + + /* Dequeues an item and returns it. If the queue is empty, the value + * 'undefined' is returned. + */ + dequeue(): T | undefined { + + // if the queue is empty, return immediately + if (this.queue.length === 0) return undefined; + + // store the item at the front of the queue + const item = this.queue[this.offset]; + + // for GC's sake + this.queue[this.offset] = null; + + // increment the offset and remove the free space if necessary + if (++this.offset * 2 >= this.queue.length) { + this.queue = this.queue.slice(this.offset); + this.offset = 0; + } + + // return the dequeued item + return item; + } + + /* Returns the item at the front of the queue (without dequeuing it). If the + * queue is empty then undefined is returned. + */ + peek(): T | undefined { + return (this.queue.length > 0 ? this.queue[this.offset] : undefined); + } + + drain(onEach: (item: T) => void): void { + while (this.getLength()) { + const item = this.dequeue()!; + onEach(item); + } + } +} diff --git a/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/throttling.ts b/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/throttling.ts new file mode 100644 index 00000000000000..8bf1b6d9c60f11 --- /dev/null +++ b/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/throttling.ts @@ -0,0 +1,61 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +import { dotnetApi, dotnetNativeBrowserExports } from "./cross-module"; +import { jsInteropState } from "./marshal"; +import { ENVIRONMENT_IS_WEB } from "./per-module"; +import { isRuntimeRunning } from "./utils"; + +let spreadTimersMaximum = 0; +const antiThrottlingIds: Set = new Set(); + +export function initializeScheduling(): void { + if (ENVIRONMENT_IS_WEB && globalThis.navigator) { + const navigator: any = globalThis.navigator; + const brands = navigator.userAgentData && navigator.userAgentData.brands; + if (brands && brands.length > 0) { + jsInteropState.isChromium = brands.some((b: any) => b.brand === "Google Chrome" || b.brand === "Microsoft Edge" || b.brand === "Chromium"); + } else if (navigator.userAgent) { + jsInteropState.isChromium = navigator.userAgent.includes("Chrome"); + jsInteropState.isFirefox = navigator.userAgent.includes("Firefox"); + } + } +} + +export function stopThrottlingPrevention(): void { + for (const id of antiThrottlingIds) { + globalThis.clearTimeout(id); + } + antiThrottlingIds.clear(); + spreadTimersMaximum = 0; +} + +export function preventTimerThrottling(): void { + if (!jsInteropState.isChromium) { + return; + } + + // this will schedule timers every second for next 6 minutes, it should be called from WebSocket event, to make it work + // on next call, it would only extend the timers to cover yet uncovered future + const now = new Date().valueOf(); + const desiredReachTime = now + (1000 * 60 * 6); + const nextReachTime = Math.max(now + 1000, spreadTimersMaximum); + const lightThrottlingFrequency = 1000; + for (let schedule = nextReachTime; schedule < desiredReachTime; schedule += lightThrottlingFrequency) { + const delay = schedule - now; + const id = { + value: -1, + }; + id.value = dotnetApi.Module.safeSetTimeout(() => preventTimerThrottlingTick(id), delay); + antiThrottlingIds.add(id.value); + } + spreadTimersMaximum = desiredReachTime; + + function preventTimerThrottlingTick(id: { value: number }) { + antiThrottlingIds.delete(id.value); + if (!isRuntimeRunning()) { + return; + } + dotnetNativeBrowserExports.runBackgroundTicks(); + } +} diff --git a/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/utils.ts b/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/utils.ts index e2d02c2bbb7fe1..a4214c5da1c7ce 100644 --- a/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/utils.ts +++ b/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/utils.ts @@ -61,3 +61,14 @@ export function endMeasure(start: TimeStamp, block: string, id?: string) { globalThis.performance.measure(name, options); } } + +let textDecoderUtf8Relaxed: TextDecoder | undefined = undefined; +export function utf8ToStringRelaxed(buffer: Uint8Array): string { + if (textDecoderUtf8Relaxed === undefined) { + textDecoderUtf8Relaxed = new TextDecoder("utf-8", { fatal: false }); + } + // TODO-WASM: When threading is enabled, TextDecoder does not accept a view of a + // SharedArrayBuffer, we must make a copy of the array first. + // See https://github.com/whatwg/encoding/issues/172 + return textDecoderUtf8Relaxed.decode(buffer); +} diff --git a/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/web-socket.ts b/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/web-socket.ts new file mode 100644 index 00000000000000..6aeae4fc6154cf --- /dev/null +++ b/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/web-socket.ts @@ -0,0 +1,503 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +import type { EmscriptenModuleInternal, PromiseCompletionSource, VoidPtr } from "./types"; + +import { preventTimerThrottling } from "./throttling"; +import { Queue } from "./queue"; +import { ENVIRONMENT_IS_NODE, ENVIRONMENT_IS_SHELL } from "./per-module"; +import { assertJsInterop, utf8ToStringRelaxed } from "./utils"; +import { fixupPointer } from "./utils"; +import { dotnetApi, dotnetAssert, dotnetBrowserUtilsExports, dotnetLoaderExports, dotnetLogger } from "./cross-module"; +import { wrapAsCancelable } from "./cancelable-promise"; + +const wasmWsPendingSendBuffer = Symbol.for("wasm ws_pending_send_buffer"); +const wasmWsPendingSendBufferOffset = Symbol.for("wasm ws_pending_send_buffer_offset"); +const wasmWsPendingSendBufferType = Symbol.for("wasm ws_pending_send_buffer_type"); +const wasmWsPendingReceiveEventQueue = Symbol.for("wasm ws_pending_receive_event_queue"); +const wasmWsPendingReceivePromiseQueue = Symbol.for("wasm ws_pending_receive_promise_queue"); +const wasmWsPendingOpenPromise = Symbol.for("wasm ws_pending_open_promise"); +const wasmWsPendingOpenPromiseUsed = Symbol.for("wasm wasm_ws_pending_open_promise_used"); +const wasmWsPendingError = Symbol.for("wasm wasm_ws_pending_error"); +const wasmWsPendingClosePromises = Symbol.for("wasm ws_pending_close_promises"); +const wasmWsPendingSendPromises = Symbol.for("wasm ws_pending_send_promises"); +const wasmWsIsAborted = Symbol.for("wasm ws_is_aborted"); +const wasmWsCloseSent = Symbol.for("wasm wasm_ws_close_sent"); +const wasmWsCloseReceived = Symbol.for("wasm wasm_ws_close_received"); +const wasmWsReceiveStatusPtr = Symbol.for("wasm ws_receive_status_ptr"); + +const wsSendBufferBlockingThreshold = 65536; +const emptyBuffer = new Uint8Array(); + +export function wsGetState(ws: WebSocketExtension): number { + if (ws.readyState != WebSocket.CLOSED) { + return ws.readyState ?? -1; + } + const receiveEventQueue = ws[wasmWsPendingReceiveEventQueue]; + const queuedEventsCount = receiveEventQueue.getLength(); + if (queuedEventsCount == 0) { + return ws.readyState ?? -1; + } + return ws[wasmWsCloseSent] ? WebSocket.CLOSING : WebSocket.OPEN; +} + +export function wsCreate(uri: string, subProtocols: string[] | null, receiveStatusPtr: VoidPtr): WebSocketExtension { + verifyEnvironment(); + assertJsInterop(); + dotnetAssert.fastCheck(uri && typeof uri === "string", () => `ERR12: Invalid uri ${typeof uri}`); + let ws: WebSocketExtension; + try { + ws = new globalThis.WebSocket(uri, subProtocols || undefined) as WebSocketExtension; + } catch (error: any) { + dotnetLogger.warn("WebSocket error in ws_wasm_create: " + error.toString()); + throw error; + } + const openPromiseControl = dotnetLoaderExports.createPromiseCompletionSource(); + + ws[wasmWsPendingReceiveEventQueue] = new Queue(); + ws[wasmWsPendingReceivePromiseQueue] = new Queue(); + ws[wasmWsPendingOpenPromise] = openPromiseControl; + ws[wasmWsPendingSendPromises] = []; + ws[wasmWsPendingClosePromises] = []; + ws[wasmWsReceiveStatusPtr] = fixupPointer(receiveStatusPtr, 0); + ws.binaryType = "arraybuffer"; + const localOnOpen = () => { + try { + if (ws[wasmWsIsAborted]) return; + if (!dotnetLoaderExports.isRuntimeRunning()) return; + openPromiseControl.resolve(ws); + preventTimerThrottling(); + } catch (error: any) { + dotnetLogger.warn("failed to propagate WebSocket open event: " + error.toString()); + } + }; + const localOnMessage = (ev: MessageEvent) => { + try { + if (ws[wasmWsIsAborted]) return; + if (!dotnetLoaderExports.isRuntimeRunning()) return; + webSocketOnMessage(ws, ev); + preventTimerThrottling(); + } catch (error: any) { + dotnetLogger.warn("failed to propagate WebSocket message event: " + error.toString()); + } + }; + const localOnClose = (ev: CloseEvent) => { + try { + ws.removeEventListener("message", localOnMessage); + if (ws[wasmWsIsAborted]) return; + if (!dotnetLoaderExports.isRuntimeRunning()) return; + + ws[wasmWsCloseReceived] = true; + // do not mangle names, maps to BrowserWebSockets\BrowserInterop.cs + ws["closeStatus"] = ev.code; + ws["closeStatusDescription"] = ev.reason; + + if (ws[wasmWsPendingOpenPromiseUsed]) { + openPromiseControl.reject(new Error(ev.reason)); + } + + for (const closePromiseControl of ws[wasmWsPendingClosePromises]) { + closePromiseControl.resolve(); + } + + (dotnetApi.Module as EmscriptenModuleInternal).safeSetTimeout(() => { + const receivePromiseQueue = ws[wasmWsPendingReceivePromiseQueue]; + receivePromiseQueue.drain((receivePromiseControl: ReceivePromiseControl) => { + dotnetApi.setHeapI32(receiveStatusPtr, 0); // count + dotnetApi.setHeapI32(receiveStatusPtr + 4, 2); // type:close + dotnetApi.setHeapI32(receiveStatusPtr + 8, 1); // end_of_message: true + receivePromiseControl.resolve(); + }); + }, 0); + } catch (error: any) { + dotnetLogger.warn("failed to propagate WebSocket close event: " + error.toString()); + } + }; + const localOnError = (ev: any) => { + try { + if (ws[wasmWsIsAborted]) return; + if (!dotnetLoaderExports.isRuntimeRunning()) return; + ws.removeEventListener("message", localOnMessage); + const message = ev.message + ? "WebSocket error: " + ev.message + : "WebSocket error"; + dotnetLogger.warn(message); + ws[wasmWsPendingError] = message; + rejectPromises(ws, new Error(message)); + } catch (error: any) { + dotnetLogger.warn("failed to propagate WebSocket error event: " + error.toString()); + } + }; + ws.addEventListener("message", localOnMessage); + ws.addEventListener("open", localOnOpen, { once: true }); + ws.addEventListener("close", localOnClose, { once: true }); + ws.addEventListener("error", localOnError, { once: true }); + ws.dispose = () => { + ws.removeEventListener("message", localOnMessage); + ws.removeEventListener("open", localOnOpen); + ws.removeEventListener("close", localOnClose); + ws.removeEventListener("error", localOnError); + wsAbort(ws); + }; + + return ws; +} + +export function wsOpen(ws: WebSocketExtension): Promise | null { + dotnetAssert.check(!!ws, "ERR17: expected ws instance"); + if (ws[wasmWsPendingError]) { + return rejectedPromise(ws[wasmWsPendingError]); + } + const openPromiseControl = ws[wasmWsPendingOpenPromise]; + ws[wasmWsPendingOpenPromiseUsed] = true; + return openPromiseControl.promise; +} + +export function wsSend(ws: WebSocketExtension, bufferPtr: VoidPtr, bufferLength: number, messageType: number, endOfMessage: boolean): Promise | null { + dotnetAssert.check(!!ws, "ERR17: expected ws instance"); + + if (ws[wasmWsPendingError]) { + return rejectedPromise(ws[wasmWsPendingError]); + } + if (ws[wasmWsIsAborted] || ws[wasmWsCloseSent]) { + return rejectedPromise("InvalidState: The WebSocket is not connected."); + } + if (ws.readyState == WebSocket.CLOSED) { + // this is server initiated close but not partial close + // because CloseOutputAsync_ServerInitiated_CanSend expectations, we don't fail here + return resolvedPromise(); + } + + const bufferView = new Uint8Array(dotnetApi.localHeapViewU8().buffer, fixupPointer(bufferPtr, 0), bufferLength); + const wholeBuffer = webSocketSendBuffering(ws, bufferView, messageType, endOfMessage); + + if (!endOfMessage || !wholeBuffer) { + return resolvedPromise(); + } + + return webSocketSendAndWait(ws, wholeBuffer); +} + +export function wsReceive(ws: WebSocketExtension, bufferPtr: VoidPtr, bufferLength: number): Promise | null { + dotnetAssert.check(!!ws, "ERR18: expected ws instance"); + + if (ws[wasmWsPendingError]) { + return rejectedPromise(ws[wasmWsPendingError]); + } + + if (ws[wasmWsIsAborted]) { + const receiveStatusPtr = ws[wasmWsReceiveStatusPtr]; + dotnetApi.setHeapI32(receiveStatusPtr, 0); + dotnetApi.setHeapI32(receiveStatusPtr + 4, 2); + dotnetApi.setHeapI32(receiveStatusPtr + 8, 1); + return resolvedPromise(); + } + + const receiveEventQueue = ws[wasmWsPendingReceiveEventQueue]; + const receivePromiseQueue = ws[wasmWsPendingReceivePromiseQueue]; + + if (receiveEventQueue.getLength()) { + dotnetAssert.check(receivePromiseQueue.getLength() == 0, "ERR20: Invalid WS state"); + + webSocketReceiveBuffering(ws, receiveEventQueue, bufferPtr, bufferLength); + + return resolvedPromise(); + } + + if (ws[wasmWsCloseReceived]) { + const receiveStatusPtr = ws[wasmWsReceiveStatusPtr]; + dotnetApi.setHeapI32(receiveStatusPtr, 0); // count + dotnetApi.setHeapI32(receiveStatusPtr + 4, 2); // type:close + dotnetApi.setHeapI32(receiveStatusPtr + 8, 1); // end_of_message: true + return resolvedPromise(); + } + + const pcs = dotnetLoaderExports.createPromiseCompletionSource(); + const receivePromiseControl = pcs as ReceivePromiseControl; + receivePromiseControl.bufferPtr = fixupPointer(bufferPtr, 0); + receivePromiseControl.bufferLength = bufferLength; + receivePromiseQueue.enqueue(receivePromiseControl); + + return pcs.promise; +} + +export function wsClose(ws: WebSocketExtension, code: number, reason: string | null, waitForCloseReceived: boolean): Promise | null { + dotnetAssert.check(!!ws, "ERR19: expected ws instance"); + + if (ws[wasmWsIsAborted] || ws[wasmWsCloseSent] || ws.readyState == WebSocket.CLOSED) { + return resolvedPromise(); + } + if (ws[wasmWsPendingError]) { + return rejectedPromise(ws[wasmWsPendingError]); + } + ws[wasmWsCloseSent] = true; + if (waitForCloseReceived) { + const pcs = dotnetLoaderExports.createPromiseCompletionSource(); + ws[wasmWsPendingClosePromises].push(pcs); + + if (typeof reason === "string") { + ws.close(code, reason); + } else { + ws.close(code); + } + return pcs.promise; + } else { + if (typeof reason === "string") { + ws.close(code, reason); + } else { + ws.close(code); + } + return resolvedPromise(); + } +} + +export function wsAbort(ws: WebSocketExtension): void { + dotnetAssert.check(!!ws, "ERR18: expected ws instance"); + + if (ws[wasmWsIsAborted] || ws[wasmWsCloseSent]) { + return; + } + + ws[wasmWsIsAborted] = true; + rejectPromises(ws, new Error("OperationCanceledException")); + + try { + // this is different from Managed implementation + ws.close(1000, "Connection was aborted."); + } catch (error: any) { + dotnetLogger.warn("WebSocket error in ws_wasm_abort: " + error.toString()); + } +} + +function rejectPromises(ws: WebSocketExtension, error: Error) { + const openPromiseControl = ws[wasmWsPendingOpenPromise]; + const openPromiseUsed = ws[wasmWsPendingOpenPromiseUsed]; + + // when `open_promise_used` is false, we should not reject it, + // because it would be unhandled rejection. Nobody is subscribed yet. + // The subscription comes on the next call, which is `ws_wasm_open`, but cancelation/abort could happen in the meantime. + if (openPromiseControl && openPromiseUsed) { + openPromiseControl.reject(error); + } + for (const closePromiseControl of ws[wasmWsPendingClosePromises]) { + closePromiseControl.reject(error); + } + for (const sendPromiseControl of ws[wasmWsPendingSendPromises]) { + sendPromiseControl.reject(error); + } + + ws[wasmWsPendingReceivePromiseQueue].drain((receivePromiseControl: ReceivePromiseControl) => { + receivePromiseControl.reject(error); + }); +} + +// send and return promise +function webSocketSendAndWait(ws: WebSocketExtension, bufferView: Uint8Array | string): Promise | null { + ws.send(bufferView); + ws[wasmWsPendingSendBuffer] = null; + + // if the remaining send buffer is small, we don't block so that the throughput doesn't suffer. + // Otherwise we block so that we apply some backpresure to the application sending large data. + // this is different from Managed implementation + if (ws.bufferedAmount < wsSendBufferBlockingThreshold) { + return resolvedPromise(); + } + + // block the promise/task until the browser passed the buffer to OS + const pcs = dotnetLoaderExports.createPromiseCompletionSource(); + const pending = ws[wasmWsPendingSendPromises]; + pending.push(pcs); + + let nextDelay = 1; + const pollingCheck = () => { + try { + if (ws.bufferedAmount === 0) { + pcs.resolve(); + } else { + const readyState = ws.readyState; + if (readyState != WebSocket.OPEN && readyState != WebSocket.CLOSING) { + // only reject if the data were not sent + // bufferedAmount does not reset to zero once the connection closes + pcs.reject(new Error(`InvalidState: ${readyState} The WebSocket is not connected.`)); + } else if (!pcs.isDone) { + globalThis.setTimeout(pollingCheck, nextDelay); + // exponentially longer delays, up to 1000ms + nextDelay = Math.min(nextDelay * 1.5, 1000); + return; + } + } + // remove from pending + const index = pending.indexOf(pcs); + if (index > -1) { + pending.splice(index, 1); + } + } catch (error: any) { + dotnetLogger.warn("WebSocket error in webSocketSendAndWait: " + error.toString()); + pcs.reject(error); + } + }; + + globalThis.setTimeout(pollingCheck, 0); + + return pcs.promise; +} + +function webSocketOnMessage(ws: WebSocketExtension, event: MessageEvent) { + const eventQueue = ws[wasmWsPendingReceiveEventQueue]; + const promiseQueue = ws[wasmWsPendingReceivePromiseQueue]; + + if (typeof event.data === "string") { + eventQueue.enqueue({ + type: 0, // WebSocketMessageType.Text + // according to the spec https://encoding.spec.whatwg.org/ + // - Unpaired surrogates will get replaced with 0xFFFD + // - utf8 encode specifically is defined to never throw + data: dotnetBrowserUtilsExports.stringToUTF8(event.data), + offset: 0 + }); + } else { + if (event.data.constructor.name !== "ArrayBuffer") { + throw new Error("ERR19: WebSocket receive expected ArrayBuffer"); + } + eventQueue.enqueue({ + type: 1, // WebSocketMessageType.Binary + data: new Uint8Array(event.data), + offset: 0 + }); + } + if (promiseQueue.getLength() && eventQueue.getLength() > 1) { + throw new Error("ERR21: Invalid WS state");// assert + } + while (promiseQueue.getLength() && eventQueue.getLength()) { + const promiseControl = promiseQueue.dequeue()!; + webSocketReceiveBuffering(ws, eventQueue, promiseControl.bufferPtr, promiseControl.bufferLength); + promiseControl.resolve(); + } + preventTimerThrottling(); +} + +function webSocketReceiveBuffering(ws: WebSocketExtension, eventQueue: Queue, bufferPtr: VoidPtr, bufferLength: number) { + const event = eventQueue.peek(); + + const count = Math.min(bufferLength, event.data.length - event.offset); + if (count > 0) { + const sourceView = event.data.subarray(event.offset, event.offset + count); + const bufferView = new Uint8Array(dotnetApi.localHeapViewU8().buffer, fixupPointer(bufferPtr, 0), bufferLength); + bufferView.set(sourceView, 0); + event.offset += count; + } + const endOfMessage = event.data.length === event.offset ? 1 : 0; + if (endOfMessage) { + eventQueue.dequeue(); + } + const responsePtr = ws[wasmWsReceiveStatusPtr]; + dotnetApi.setHeapI32(responsePtr, count); + dotnetApi.setHeapI32(responsePtr + 4, event.type); + dotnetApi.setHeapI32(responsePtr + 8, endOfMessage); +} + +function webSocketSendBuffering(ws: WebSocketExtension, bufferView: Uint8Array, messageType: number, endOfMessage: boolean): Uint8Array | string | null { + let buffer = ws[wasmWsPendingSendBuffer]; + let offset = 0; + const length = bufferView.byteLength; + + if (buffer) { + offset = ws[wasmWsPendingSendBufferOffset]; + messageType = ws[wasmWsPendingSendBufferType]; + if (length !== 0) { + if (offset + length > buffer.length) { + const newbuffer = new Uint8Array((offset + length + 50) * 1.5); // exponential growth + newbuffer.set(buffer, 0);// copy previous buffer + newbuffer.subarray(offset).set(bufferView);// append copy at the end + ws[wasmWsPendingSendBuffer] = buffer = newbuffer; + } else { + buffer.subarray(offset).set(bufferView);// append copy at the end + } + offset += length; + ws[wasmWsPendingSendBufferOffset] = offset; + } + } else if (!endOfMessage) { + if (length !== 0) { + buffer = bufferView.slice(); // copy + offset = length; + ws[wasmWsPendingSendBufferOffset] = offset; + ws[wasmWsPendingSendBuffer] = buffer; + } + ws[wasmWsPendingSendBufferType] = messageType; + } else { + if (length !== 0) { + // TODO-WASM: copy, because the provided ArrayBufferView value must not be shared in MT. + buffer = bufferView; + offset = length; + } + } + if (endOfMessage) { + if (offset == 0 || buffer == null) { + return emptyBuffer; + } + if (messageType === 0) { + // text, convert from UTF-8 bytes to string, because of bad browser API + const bytes = buffer.subarray(0, offset >>> 0); + // we do not validate outgoing data https://github.com/dotnet/runtime/issues/59214 + return utf8ToStringRelaxed(bytes); + } else { + // binary, view to used part of the buffer + return buffer.subarray(0, offset); + } + } + return null; +} + +type WebSocketExtension = WebSocket & { + [wasmWsPendingReceiveEventQueue]: Queue; + [wasmWsPendingReceivePromiseQueue]: Queue; + [wasmWsPendingOpenPromise]: PromiseCompletionSource; + [wasmWsPendingOpenPromiseUsed]: boolean; + [wasmWsPendingSendPromises]: PromiseCompletionSource[]; + [wasmWsPendingClosePromises]: PromiseCompletionSource[]; + [wasmWsPendingError]: string | undefined; + [wasmWsIsAborted]: boolean; + [wasmWsCloseReceived]: boolean; + [wasmWsCloseSent]: boolean; + [wasmWsReceiveStatusPtr]: VoidPtr; + [wasmWsPendingSendBufferOffset]: number; + [wasmWsPendingSendBufferType]: number; + [wasmWsPendingSendBuffer]: Uint8Array | null; + closeStatus: number | undefined; + closeStatusDescription: string | undefined; + dispose(): void; +}; + +type ReceivePromiseControl = PromiseCompletionSource & { + bufferPtr: VoidPtr; + bufferLength: number; +} + +type Message = { + type: number, // WebSocketMessageType + data: Uint8Array, + offset: number +} + +function resolvedPromise(): Promise | null { + // signal that we are finished synchronously + // this is optimization, which doesn't allocate and doesn't require to marshal resolve() call to C# side. + return null; +} + +function rejectedPromise(message: string): Promise | null { + const resolved = Promise.reject(new Error(message)); + return wrapAsCancelable(resolved); +} + +function verifyEnvironment() { + if (ENVIRONMENT_IS_SHELL) { + throw new Error("WebSockets are not supported in shell JS engine."); + } + if (typeof globalThis.WebSocket !== "function") { + const message = ENVIRONMENT_IS_NODE + ? "Please install `ws` npm package to enable networking support. See also https://aka.ms/dotnet-wasm-features" + : "This browser doesn't support WebSocket API. Please use a modern browser. See also https://aka.ms/dotnet-wasm-features"; + throw new Error(message); + } +} From d1446a8afb1bd44fe190bbb83b7029fce2fbc211 Mon Sep 17 00:00:00 2001 From: pavelsavara Date: Wed, 21 Jan 2026 20:29:20 +0100 Subject: [PATCH 2/5] more --- src/native/corehost/browserhost/loader/exit.ts | 11 +++++++---- .../libs/Common/JavaScript/CMakeLists.txt | 2 +- .../Common/JavaScript/cross-module/index.ts | 6 +++--- .../libs/Common/JavaScript/types/ems-ambient.ts | 1 - .../libs/Common/JavaScript/types/exchange.ts | 17 ++++++++--------- .../System.Native.Browser/diagnostics/exit.ts | 1 - .../libSystem.Native.Browser.footer.js | 5 ++++- .../libs/System.Native.Browser/native/index.ts | 4 +--- .../System.Native.Browser/native/scheduling.ts | 5 ----- .../libs/System.Native.Browser/utils/host.ts | 7 ++++++- .../libs/System.Native.Browser/utils/index.ts | 8 +++++--- .../interop/index.ts | 6 +++--- .../interop/{throttling.ts => scheduling.ts} | 16 ++++++++-------- .../interop/web-socket.ts | 2 +- 14 files changed, 47 insertions(+), 44 deletions(-) rename src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/{throttling.ts => scheduling.ts} (84%) diff --git a/src/native/corehost/browserhost/loader/exit.ts b/src/native/corehost/browserhost/loader/exit.ts index 08ff08ef7d9721..78ecb485d7d96a 100644 --- a/src/native/corehost/browserhost/loader/exit.ts +++ b/src/native/corehost/browserhost/loader/exit.ts @@ -2,7 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. import type { OnExitListener } from "../types"; -import { dotnetLogger, dotnetLoaderExports, Module, dotnetBrowserUtilsExports } from "./cross-module"; +import { dotnetLogger, dotnetLoaderExports, Module, dotnetBrowserUtilsExports, dotnetRuntimeExports } from "./cross-module"; import { ENVIRONMENT_IS_NODE, ENVIRONMENT_IS_WEB } from "./per-module"; export const runtimeState = { @@ -106,6 +106,12 @@ export function exit(exitCode: number, reason: any): void { runtimeState.exitReason = reason; try { + if (dotnetRuntimeExports && dotnetRuntimeExports.abortInteropTimers) { + dotnetRuntimeExports.abortInteropTimers(); + } + if (dotnetBrowserUtilsExports && dotnetBrowserUtilsExports.abortBackgroundTimers) { + dotnetBrowserUtilsExports.abortBackgroundTimers(); + } unregisterExit(); if (!alreadySilent) { if (runtimeState.onExitListeners.length === 0 && !runtimeState.runtimeReady) { @@ -144,9 +150,6 @@ export function exit(exitCode: number, reason: any): void { export function quitNow(exitCode: number, reason?: any): void { if (runtimeState.runtimeReady) { Module.runtimeKeepalivePop(); - if (dotnetBrowserUtilsExports && dotnetBrowserUtilsExports.abortTimers) { - dotnetBrowserUtilsExports.abortTimers(); - } if (dotnetBrowserUtilsExports && dotnetBrowserUtilsExports.abortPosix) { dotnetBrowserUtilsExports.abortPosix(exitCode); } diff --git a/src/native/libs/Common/JavaScript/CMakeLists.txt b/src/native/libs/Common/JavaScript/CMakeLists.txt index a6c6e673c48602..6a26a0c1e24e4e 100644 --- a/src/native/libs/Common/JavaScript/CMakeLists.txt +++ b/src/native/libs/Common/JavaScript/CMakeLists.txt @@ -92,7 +92,7 @@ set(ROLLUP_TS_SOURCES "${CLR_SRC_NATIVE_DIR}/libs/System.Runtime.InteropServices.JavaScript.Native/interop/marshaled-types.ts" "${CLR_SRC_NATIVE_DIR}/libs/System.Runtime.InteropServices.JavaScript.Native/interop/per-module.ts" "${CLR_SRC_NATIVE_DIR}/libs/System.Runtime.InteropServices.JavaScript.Native/interop/queue.ts" - "${CLR_SRC_NATIVE_DIR}/libs/System.Runtime.InteropServices.JavaScript.Native/interop/throttling.ts" + "${CLR_SRC_NATIVE_DIR}/libs/System.Runtime.InteropServices.JavaScript.Native/interop/scheduling.ts" "${CLR_SRC_NATIVE_DIR}/libs/System.Runtime.InteropServices.JavaScript.Native/interop/types.ts" "${CLR_SRC_NATIVE_DIR}/libs/System.Runtime.InteropServices.JavaScript.Native/interop/utils.ts" "${CLR_SRC_NATIVE_DIR}/libs/System.Runtime.InteropServices.JavaScript.Native/interop/weak-ref.ts" diff --git a/src/native/libs/Common/JavaScript/cross-module/index.ts b/src/native/libs/Common/JavaScript/cross-module/index.ts index dd1259505f3eca..0976649a3c8ca8 100644 --- a/src/native/libs/Common/JavaScript/cross-module/index.ts +++ b/src/native/libs/Common/JavaScript/cross-module/index.ts @@ -107,7 +107,7 @@ export function dotnetUpdateInternalsSubscriber() { cancelPromise: table[4], invokeJSFunction: table[5], forceDisposeProxies: table[6], - stopThrottlingPrevention: table[7], + abortInteropTimers: table[7], }; Object.assign(runtime, runtimerLocal); } @@ -170,7 +170,6 @@ export function dotnetUpdateInternalsSubscriber() { // keep in sync with nativeBrowserExportsToTable() function nativeBrowserExportsFromTable(table: NativeBrowserExportsTable, interop: NativeBrowserExports): void { const interopLocal: NativeBrowserExports = { - runBackgroundTicks: table[0], }; Object.assign(interop, interopLocal); } @@ -193,9 +192,10 @@ export function dotnetUpdateInternalsSubscriber() { stringToUTF8: table[4], zeroRegion: table[5], isSharedArrayBuffer: table[6], - abortTimers: table[7], + abortBackgroundTimers: table[7], abortPosix: table[8], getExitStatus: table[9], + runBackgroundTimers: table[10], }; Object.assign(interop, interopLocal); } diff --git a/src/native/libs/Common/JavaScript/types/ems-ambient.ts b/src/native/libs/Common/JavaScript/types/ems-ambient.ts index 1b7992c98627b9..e4ff4f2a05a637 100644 --- a/src/native/libs/Common/JavaScript/types/ems-ambient.ts +++ b/src/native/libs/Common/JavaScript/types/ems-ambient.ts @@ -37,7 +37,6 @@ export type EmsAmbientSymbolsType = EmscriptenModuleInternal & { _SystemInteropJS_ReleaseJSOwnedObjectByGCHandle: (args: JSMarshalerArguments) => void; _SystemInteropJS_BindAssemblyExports: (args: JSMarshalerArguments) => void; _SystemInteropJS_CallJSExport: (methodHandle: CSFnHandle, args: JSMarshalerArguments) => void; - _runBackgroundTicks: () => void; FS: { createPath: (parent: string, path: string, canRead?: boolean, canWrite?: boolean) => string; diff --git a/src/native/libs/Common/JavaScript/types/exchange.ts b/src/native/libs/Common/JavaScript/types/exchange.ts index 3c8f19f69d355c..c72ccf8d8a3e5e 100644 --- a/src/native/libs/Common/JavaScript/types/exchange.ts +++ b/src/native/libs/Common/JavaScript/types/exchange.ts @@ -10,15 +10,14 @@ import type { createPromiseCompletionSource, getPromiseCompletionSource, isContr import type { isSharedArrayBuffer, zeroRegion } from "../../../System.Native.Browser/utils/memory"; import type { stringToUTF16, stringToUTF16Ptr, stringToUTF8, stringToUTF8Ptr, utf16ToString } from "../../../System.Native.Browser/utils/strings"; -import type { abortPosix, abortTimers, getExitStatus } from "../../../System.Native.Browser/utils/host"; +import type { abortPosix, abortBackgroundTimers, getExitStatus, runBackgroundTimers } from "../../../System.Native.Browser/utils/host"; import type { bindJSImportST, invokeJSFunction, invokeJSImportST } from "../../../System.Runtime.InteropServices.JavaScript.Native/interop/invoke-js"; import type { forceDisposeProxies, releaseCSOwnedObject } from "../../../System.Runtime.InteropServices.JavaScript.Native/interop/gc-handles"; import type { resolveOrRejectPromise } from "../../../System.Runtime.InteropServices.JavaScript.Native/interop/marshal-to-js"; import type { cancelPromise } from "../../../System.Runtime.InteropServices.JavaScript.Native/interop/cancelable-promise"; -import type { stopThrottlingPrevention } from "../../../System.Runtime.InteropServices.JavaScript.Native/interop/throttling"; +import type { abortInteropTimers } from "../../../System.Runtime.InteropServices.JavaScript.Native/interop/scheduling"; import type { symbolicateStackTrace } from "../../../System.Native.Browser/diagnostics/symbolicate"; -import type { runBackgroundTicks } from "../../../System.Native.Browser/native/scheduling"; import type { EmsAmbientSymbolsType } from "../types"; export type RuntimeExports = { @@ -29,7 +28,7 @@ export type RuntimeExports = { cancelPromise: typeof cancelPromise, invokeJSFunction: typeof invokeJSFunction, forceDisposeProxies: typeof forceDisposeProxies, - stopThrottlingPrevention: typeof stopThrottlingPrevention, + abortInteropTimers: typeof abortInteropTimers, } export type RuntimeExportsTable = [ @@ -40,7 +39,7 @@ export type RuntimeExportsTable = [ typeof cancelPromise, typeof invokeJSFunction, typeof forceDisposeProxies, - typeof stopThrottlingPrevention, + typeof abortInteropTimers, ] export type LoggerType = { @@ -124,11 +123,9 @@ export type InteropJavaScriptExportsTable = [ ] export type NativeBrowserExports = { - runBackgroundTicks: typeof runBackgroundTicks, } export type NativeBrowserExportsTable = [ - typeof runBackgroundTicks, ] export type BrowserUtilsExports = { @@ -139,9 +136,10 @@ export type BrowserUtilsExports = { stringToUTF8: typeof stringToUTF8, zeroRegion: typeof zeroRegion, isSharedArrayBuffer: typeof isSharedArrayBuffer - abortTimers: typeof abortTimers, + abortBackgroundTimers: typeof abortBackgroundTimers, abortPosix: typeof abortPosix, getExitStatus: typeof getExitStatus, + runBackgroundTimers: typeof runBackgroundTimers, } export type BrowserUtilsExportsTable = [ @@ -152,9 +150,10 @@ export type BrowserUtilsExportsTable = [ typeof stringToUTF8, typeof zeroRegion, typeof isSharedArrayBuffer, - typeof abortTimers, + typeof abortBackgroundTimers, typeof abortPosix, typeof getExitStatus, + typeof runBackgroundTimers, ] export type DiagnosticsExportsTable = [ diff --git a/src/native/libs/System.Native.Browser/diagnostics/exit.ts b/src/native/libs/System.Native.Browser/diagnostics/exit.ts index 6f1e1fc32c283f..abd538dc542699 100644 --- a/src/native/libs/System.Native.Browser/diagnostics/exit.ts +++ b/src/native/libs/System.Native.Browser/diagnostics/exit.ts @@ -25,7 +25,6 @@ function onExit(exitCode: number, reason: any, silent: boolean): boolean { if (!loaderConfig) { return true; } - dotnetRuntimeExports.stopThrottlingPrevention(); if (exitCode === 0 && loaderConfig.interopCleanupOnExit) { dotnetRuntimeExports.forceDisposeProxies(true, true); } diff --git a/src/native/libs/System.Native.Browser/libSystem.Native.Browser.footer.js b/src/native/libs/System.Native.Browser/libSystem.Native.Browser.footer.js index 48a68c0b064882..a29bb4db959fd7 100644 --- a/src/native/libs/System.Native.Browser/libSystem.Native.Browser.footer.js +++ b/src/native/libs/System.Native.Browser/libSystem.Native.Browser.footer.js @@ -19,7 +19,10 @@ const exports = {}; libNativeBrowser(exports); - let commonDeps = ["$BROWSER_UTILS", "SystemJS_ExecuteTimerCallback", "SystemJS_ExecuteBackgroundJobCallback", "runBackgroundTicks"]; + let commonDeps = [ + "$BROWSER_UTILS", + "SystemJS_ExecuteTimerCallback", "SystemJS_ExecuteBackgroundJobCallback" + ]; const lib = { $DOTNET: { selfInitialize: () => { diff --git a/src/native/libs/System.Native.Browser/native/index.ts b/src/native/libs/System.Native.Browser/native/index.ts index 3e53e96c18a73b..fee0bb46e8d6d2 100644 --- a/src/native/libs/System.Native.Browser/native/index.ts +++ b/src/native/libs/System.Native.Browser/native/index.ts @@ -10,7 +10,7 @@ import GitHash from "consts:gitHash"; export { SystemJS_RandomBytes } from "./crypto"; export { SystemJS_GetLocaleInfo } from "./globalization-locale"; export { SystemJS_RejectMainPromise, SystemJS_ResolveMainPromise, SystemJS_ConsoleClear } from "./main"; -export { SystemJS_ScheduleTimer, SystemJS_ScheduleBackgroundJob, runBackgroundTicks } from "./scheduling"; +export { SystemJS_ScheduleTimer, SystemJS_ScheduleBackgroundJob } from "./scheduling"; export const gitHash = GitHash; export function dotnetInitializeModule(internals: InternalExchange): void { @@ -24,7 +24,6 @@ export function dotnetInitializeModule(internals: InternalExchange): void { } internals[InternalExchangeIndex.NativeBrowserExportsTable] = nativeBrowserExportsToTable({ - runBackgroundTicks: _ems_._runBackgroundTicks, }); _ems_.dotnetUpdateInternals(internals, _ems_.dotnetUpdateInternalsSubscriber); @@ -32,7 +31,6 @@ export function dotnetInitializeModule(internals: InternalExchange): void { function nativeBrowserExportsToTable(map: NativeBrowserExports): NativeBrowserExportsTable { // keep in sync with nativeBrowserExportsFromTable() return [ - map.runBackgroundTicks, ]; } } diff --git a/src/native/libs/System.Native.Browser/native/scheduling.ts b/src/native/libs/System.Native.Browser/native/scheduling.ts index ea8c1e370237df..bfe56333fef193 100644 --- a/src/native/libs/System.Native.Browser/native/scheduling.ts +++ b/src/native/libs/System.Native.Browser/native/scheduling.ts @@ -30,8 +30,3 @@ export function SystemJS_ScheduleBackgroundJob(): void { _ems_._SystemJS_ExecuteBackgroundJobCallback(); } } - -export function runBackgroundTicks(): void { - _ems_._SystemJS_ExecuteTimerCallback(); - _ems_._SystemJS_ExecuteBackgroundJobCallback(); -} diff --git a/src/native/libs/System.Native.Browser/utils/host.ts b/src/native/libs/System.Native.Browser/utils/host.ts index a30df05a17a2a4..1a7ab6a19292b1 100644 --- a/src/native/libs/System.Native.Browser/utils/host.ts +++ b/src/native/libs/System.Native.Browser/utils/host.ts @@ -12,7 +12,12 @@ export function getExitStatus(): new (exitCode: number) => any { return _ems_.ExitStatus as any; } -export function abortTimers(): void { +export function runBackgroundTimers(): void { + _ems_._SystemJS_ExecuteTimerCallback(); + _ems_._SystemJS_ExecuteBackgroundJobCallback(); +} + +export function abortBackgroundTimers(): void { if (_ems_.DOTNET.lastScheduledTimerId) { globalThis.clearTimeout(_ems_.DOTNET.lastScheduledTimerId); _ems_.runtimeKeepalivePop(); diff --git a/src/native/libs/System.Native.Browser/utils/index.ts b/src/native/libs/System.Native.Browser/utils/index.ts index e137a1959b415b..402edf73bffb28 100644 --- a/src/native/libs/System.Native.Browser/utils/index.ts +++ b/src/native/libs/System.Native.Browser/utils/index.ts @@ -14,7 +14,7 @@ import { isSharedArrayBuffer, } from "./memory"; import { stringToUTF16, stringToUTF16Ptr, stringToUTF8, stringToUTF8Ptr, utf16ToString } from "./strings"; -import { abortPosix, abortTimers, getExitStatus, setEnvironmentVariable } from "./host"; +import { abortPosix, abortBackgroundTimers, getExitStatus, setEnvironmentVariable, runBackgroundTimers } from "./host"; import { dotnetUpdateInternals, dotnetUpdateInternalsSubscriber } from "../utils/cross-module"; import { initPolyfills } from "../utils/polyfills"; import { registerRuntime } from "./runtime-list"; @@ -50,9 +50,10 @@ export function dotnetInitializeModule(internals: InternalExchange): void { stringToUTF8, zeroRegion, isSharedArrayBuffer, - abortTimers, + abortBackgroundTimers, abortPosix, getExitStatus, + runBackgroundTimers, }); dotnetUpdateInternals(internals, dotnetUpdateInternalsSubscriber); function browserUtilsExportsToTable(map: BrowserUtilsExports): BrowserUtilsExportsTable { @@ -65,9 +66,10 @@ export function dotnetInitializeModule(internals: InternalExchange): void { map.stringToUTF8, map.zeroRegion, map.isSharedArrayBuffer, - map.abortTimers, + map.abortBackgroundTimers, map.abortPosix, map.getExitStatus, + map.runBackgroundTimers, ]; } } diff --git a/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/index.ts b/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/index.ts index f618a8179aca3f..8cc5a92ac823fa 100644 --- a/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/index.ts +++ b/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/index.ts @@ -18,7 +18,7 @@ import { forceDisposeProxies, releaseCSOwnedObject } from "./gc-handles"; import { cancelPromise } from "./cancelable-promise"; import { loadLazyAssembly, loadSatelliteAssemblies } from "./lazy"; import { jsInteropState } from "./marshal"; -import { initializeScheduling, stopThrottlingPrevention } from "./throttling"; +import { initializeScheduling, abortInteropTimers } from "./scheduling"; import { wsAbort, wsClose, wsCreate, wsGetState, wsOpen, wsReceive, wsSend } from "./web-socket"; import { httpSupportsStreamingRequest, httpSupportsStreamingResponse, httpCreateController, httpGetResponseType, @@ -89,7 +89,7 @@ export function dotnetInitializeModule(internals: InternalExchange): void { cancelPromise, invokeJSFunction, forceDisposeProxies, - stopThrottlingPrevention, + abortInteropTimers, }); dotnetUpdateInternals(internals, dotnetUpdateInternalsSubscriber); @@ -109,7 +109,7 @@ export function dotnetInitializeModule(internals: InternalExchange): void { map.cancelPromise, map.invokeJSFunction, map.forceDisposeProxies, - map.stopThrottlingPrevention, + map.abortInteropTimers, ]; } } diff --git a/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/throttling.ts b/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/scheduling.ts similarity index 84% rename from src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/throttling.ts rename to src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/scheduling.ts index 8bf1b6d9c60f11..ceb9996ae68c1d 100644 --- a/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/throttling.ts +++ b/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/scheduling.ts @@ -1,13 +1,13 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -import { dotnetApi, dotnetNativeBrowserExports } from "./cross-module"; +import { dotnetApi, dotnetBrowserUtilsExports } from "./cross-module"; import { jsInteropState } from "./marshal"; import { ENVIRONMENT_IS_WEB } from "./per-module"; import { isRuntimeRunning } from "./utils"; let spreadTimersMaximum = 0; -const antiThrottlingIds: Set = new Set(); +const pendingJsTimers: Set = new Set(); export function initializeScheduling(): void { if (ENVIRONMENT_IS_WEB && globalThis.navigator) { @@ -22,11 +22,11 @@ export function initializeScheduling(): void { } } -export function stopThrottlingPrevention(): void { - for (const id of antiThrottlingIds) { +export function abortInteropTimers(): void { + for (const id of pendingJsTimers) { globalThis.clearTimeout(id); } - antiThrottlingIds.clear(); + pendingJsTimers.clear(); spreadTimersMaximum = 0; } @@ -47,15 +47,15 @@ export function preventTimerThrottling(): void { value: -1, }; id.value = dotnetApi.Module.safeSetTimeout(() => preventTimerThrottlingTick(id), delay); - antiThrottlingIds.add(id.value); + pendingJsTimers.add(id.value); } spreadTimersMaximum = desiredReachTime; function preventTimerThrottlingTick(id: { value: number }) { - antiThrottlingIds.delete(id.value); + pendingJsTimers.delete(id.value); if (!isRuntimeRunning()) { return; } - dotnetNativeBrowserExports.runBackgroundTicks(); + dotnetBrowserUtilsExports.runBackgroundTimers(); } } diff --git a/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/web-socket.ts b/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/web-socket.ts index 6aeae4fc6154cf..94a90caaad6c27 100644 --- a/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/web-socket.ts +++ b/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/web-socket.ts @@ -3,7 +3,7 @@ import type { EmscriptenModuleInternal, PromiseCompletionSource, VoidPtr } from "./types"; -import { preventTimerThrottling } from "./throttling"; +import { preventTimerThrottling } from "./scheduling"; import { Queue } from "./queue"; import { ENVIRONMENT_IS_NODE, ENVIRONMENT_IS_SHELL } from "./per-module"; import { assertJsInterop, utf8ToStringRelaxed } from "./utils"; From 402fe2dad184df37fab7f2edbbcc5b7eea9af24f Mon Sep 17 00:00:00 2001 From: Pavel Savara Date: Wed, 21 Jan 2026 20:51:18 +0100 Subject: [PATCH 3/5] Update src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/web-socket.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../interop/web-socket.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/web-socket.ts b/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/web-socket.ts index 94a90caaad6c27..825e10a9407206 100644 --- a/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/web-socket.ts +++ b/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/web-socket.ts @@ -357,7 +357,7 @@ function webSocketOnMessage(ws: WebSocketExtension, event: MessageEvent) { }); } else { if (event.data.constructor.name !== "ArrayBuffer") { - throw new Error("ERR19: WebSocket receive expected ArrayBuffer"); + throw new Error("ERR22: WebSocket receive expected ArrayBuffer"); } eventQueue.enqueue({ type: 1, // WebSocketMessageType.Binary From c37ecc61d37e4981577feb1da2cf631cb00c9522 Mon Sep 17 00:00:00 2001 From: Pavel Savara Date: Wed, 21 Jan 2026 20:51:54 +0100 Subject: [PATCH 4/5] Update src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/queue.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../interop/queue.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/queue.ts b/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/queue.ts index 74f507671ff98c..20a9f7e6acbf69 100644 --- a/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/queue.ts +++ b/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/queue.ts @@ -10,7 +10,6 @@ export class Queue { this.queue = []; this.offset = 0; } - // initialise the queue and offset // Returns the length of the queue. getLength(): number { From 621db50add7b819a31b8fbe9171a0c68e1bf3078 Mon Sep 17 00:00:00 2001 From: Pavel Savara Date: Wed, 21 Jan 2026 20:52:25 +0100 Subject: [PATCH 5/5] Update src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/queue.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../interop/queue.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/queue.ts b/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/queue.ts index 20a9f7e6acbf69..9abeff3d25d78f 100644 --- a/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/queue.ts +++ b/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/queue.ts @@ -60,6 +60,9 @@ export class Queue { return (this.queue.length > 0 ? this.queue[this.offset] : undefined); } + /** Drains the queue by dequeuing all items and invoking the provided callback for each item. + * @param onEach - A function to invoke for each item dequeued from the queue. + */ drain(onEach: (item: T) => void): void { while (this.getLength()) { const item = this.dequeue()!;