From 755e0041c7d531d4ebc6460d56d99ab07369e12f Mon Sep 17 00:00:00 2001 From: pavelsavara Date: Wed, 16 Sep 2026 17:21:54 +0200 Subject: [PATCH 01/14] [browser] release eagerly created Task proxies on non-normal paths Marshalling a Task to JS eagerly creates a TaskHolder and hands managed code its JSHandle. Two paths never released it: a JSExport whose Task was already completed, and getAssemblyExports failing before the promise was handed over. The promise now carries the handle number rather than the holder itself, so the holder is not retained once the handle is released. Also adds INTERNAL.getProxyCensus, which the tests use to count live proxies. Contributes to https://github.com/dotnet/runtime/issues/132966 --- .../interop/gc-handles.ts | 28 +++++++++++++++++++ .../interop/index.ts | 3 +- .../interop/managed-exports.ts | 4 +-- .../interop/marshal-to-js.ts | 17 +++++++++-- 4 files changed, 46 insertions(+), 6 deletions(-) diff --git a/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/gc-handles.ts b/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/gc-handles.ts index e33c23262a636a..1dbdb699335dd5 100644 --- a/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/gc-handles.ts +++ b/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/gc-handles.ts @@ -16,6 +16,8 @@ export const boundJsFunctionSymbol = Symbol.for("wasm bound_js_function"); export const importedJsFunctionSymbol = Symbol.for("wasm imported_js_function"); export const proxyDebugSymbol = Symbol.for("wasm proxyDebug"); export const promiseHolderSymbol = Symbol.for("wasm promise_holder"); +// links an eagerly created Promise back to the JSHandle of its TaskHolder +export const eagerTaskHandleSymbol = Symbol.for("wasm eager_task_handle"); let forceDisposeProxiesInProgress = false; @@ -194,6 +196,32 @@ function _jsOwnedObjectFinalized(gcHandle: GCHandle): void { teardownManagedProxy(null, gcHandle); } +// Counts of live proxies, for leak diagnostics and tests. Exposed as INTERNAL.getProxyCensus. +// Order: [csOwnedByJsHandle, csOwnedByJsvHandle, jsOwnedRegistered, jsOwnedAlive, importWrappers] +export function getProxyCensus(): number[] { + // index 0 of each list is always a dummy + const countLive = (list: any[]): number => { + let live = 0; + for (let i = 1; i < list.length; i++) { + if (list[i] !== undefined && list[i] !== null) live++; + } + return live; + }; + + let jsOwnedAlive = 0; + for (const wr of jsOwnedObjectTable.values()) { + if (wr.deref() !== undefined) jsOwnedAlive++; + } + + return [ + countLive(_CsOwnedObjectsByJsHandle), + countLive(_CsOwnedObjectsByJsvHandle), + jsOwnedObjectTable.size, + jsOwnedAlive, + countLive(jsImportWrapperByFnHandle), + ]; +} + export function lookupJsOwnedObject(gcHandle: GCHandle): any { if (!gcHandle) return null; 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 8cc5a92ac823fa..4ae5e50aa54bb4 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 @@ -14,7 +14,7 @@ import { import { bindCsFunction, getAssemblyExports } from "./invoke-cs"; import { initializeMarshalersToJs, resolveOrRejectPromise } from "./marshal-to-js"; import { initializeMarshalersToCs } from "./marshal-to-cs"; -import { forceDisposeProxies, releaseCSOwnedObject } from "./gc-handles"; +import { forceDisposeProxies, getProxyCensus, releaseCSOwnedObject } from "./gc-handles"; import { cancelPromise } from "./cancelable-promise"; import { loadLazyAssembly, loadSatelliteAssemblies } from "./lazy"; import { jsInteropState } from "./marshal"; @@ -52,6 +52,7 @@ export function dotnetInitializeModule(internals: InternalExchange): void { bindCsFunction, loadSatelliteAssemblies, loadLazyAssembly, + getProxyCensus, // WebSocket wsCreate, diff --git a/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/managed-exports.ts b/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/managed-exports.ts index 8b82fc3da2b47c..6c245eca5fc479 100644 --- a/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/managed-exports.ts +++ b/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/managed-exports.ts @@ -6,7 +6,7 @@ import type { JSMarshalerArguments, GCHandle, MarshalerToCs, MarshalerToJs, CSFn import { dotnetAssert, dotnetBrowserUtilsExports, dotnetInteropJSExports, Module } from "./cross-module"; import { allocStackFrame, getArg, isArgsException, setArgType, setGcHandle } from "./marshal"; import { marshalExceptionToCs, marshalStringToCs } from "./marshal-to-cs"; -import { beginMarshalTaskToJs, endMarshalTaskToJs, marshalExceptionToJs, marshalInt32ToJs, marshalStringToJs } from "./marshal-to-js"; +import { beginMarshalTaskToJs, endMarshalTaskToJs, marshalExceptionToJs, marshalInt32ToJs, marshalStringToJs, releaseEagerTaskHolder } from "./marshal-to-js"; import { assertJsInterop, assertRuntimeRunning, isRuntimeRunning } from "./utils"; import { MarshalerType } from "./types"; @@ -170,7 +170,7 @@ export function bindAssemblyExports(assemblyName: string): Promise { throw error; } if (isArgsException(args)) { - // TODO free pre-created promise + releaseEagerTaskHolder(promise); const exc = getArg(args, 0); throw marshalExceptionToJs(exc); } diff --git a/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/marshal-to-js.ts b/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/marshal-to-js.ts index 52b97150814fa0..f3dc7e2fe56af8 100644 --- a/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/marshal-to-js.ts +++ b/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/marshal-to-js.ts @@ -16,7 +16,7 @@ import { isReceiverShouldFree, } from "./marshal"; import { marshalExceptionToCs } from "./marshal-to-cs"; -import { lookupJsOwnedObject, getJsHandleFromJSObject, getJSObjectFromJSHandle, registerWithJsvHandle, releaseCSOwnedObject, setupManagedProxy, teardownManagedProxy, proxyDebugSymbol } from "./gc-handles"; +import { lookupJsOwnedObject, getJsHandleFromJSObject, getJSObjectFromJSHandle, registerWithJsvHandle, releaseCSOwnedObject, setupManagedProxy, teardownManagedProxy, proxyDebugSymbol, eagerTaskHandleSymbol } from "./gc-handles"; import { assertRuntimeRunning, fixupPointer, isRuntimeRunning } from "./utils"; import { ArraySegment, ManagedError, ManagedObject, MemoryViewType, Span } from "./marshaled-types"; import { callDelegate } from "./managed-exports"; @@ -243,9 +243,21 @@ export function beginMarshalTaskToJs(arg: JSMarshalerArgument, _?: MarshalerType } setJsHandle(arg, jsHandle); setArgType(arg, MarshalerType.TaskPreCreated); + // the caller only gets the promise back, so it needs a way to find the handle again. + // storing the number rather than the holder keeps the promise from retaining it. + (holder.promise as any)[eagerTaskHandleSymbol] = jsHandle; return holder.promise; } +// the eagerly created Promise was never handed to managed code, drop its proxy +export function releaseEagerTaskHolder(eagerPromise: Promise | null | undefined): void { + if (!eagerPromise) return; + const jsHandle = (eagerPromise as any)[eagerTaskHandleSymbol]; + dotnetAssert.check(jsHandle, "Expected JSHandle on the eagerly created promise"); + (eagerPromise as any)[eagerTaskHandleSymbol] = undefined; + releaseCSOwnedObject(jsHandle); +} + export function endMarshalTaskToJs(args: JSMarshalerArguments, resConverter: MarshalerToJs | undefined, eagerPromise: Promise | null) { // this path is used when Task is returned from JSExport/call_entry_point const res = getArg(args, 1); @@ -257,8 +269,7 @@ export function endMarshalTaskToJs(args: JSMarshalerArguments, resConverter: Mar } // otherwise drop the eagerPromise's handle - const jsHandle = getJsHandleFromJSObject(eagerPromise); - releaseCSOwnedObject(jsHandle); + releaseEagerTaskHolder(eagerPromise); // get the synchronous result const promise = tryMarshalSyncTaskToJs(res, type, resConverter); From bdf2127b66b12193a053c558c6ccea8345ffac28 Mon Sep 17 00:00:00 2001 From: pavelsavara Date: Wed, 16 Sep 2026 17:22:19 +0200 Subject: [PATCH 02/14] [browser][mono] release the eagerly created Promise when a JSExport Task completes synchronously Same fix as the CoreCLR wrapper: the promise carries the TaskHolder's handle number so the holder can be released without being retained by the promise. The bindAssemblyExports leak does not exist here, as the Mono wrapper has no equivalent error branch. Contributes to https://github.com/dotnet/runtime/issues/132966 --- src/mono/browser/runtime/gc-handles.ts | 2 ++ src/mono/browser/runtime/marshal-to-js.ts | 17 ++++++++++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/mono/browser/runtime/gc-handles.ts b/src/mono/browser/runtime/gc-handles.ts index cd8bebeb4877b9..cb63692fc50fc6 100644 --- a/src/mono/browser/runtime/gc-handles.ts +++ b/src/mono/browser/runtime/gc-handles.ts @@ -60,6 +60,8 @@ if (_use_finalization_registry) { export const js_owned_gc_handle_symbol = Symbol.for("wasm js_owned_gc_handle"); export const cs_owned_js_handle_symbol = Symbol.for("wasm cs_owned_js_handle"); export const do_not_force_dispose = Symbol.for("wasm do_not_force_dispose"); +// links an eagerly created Promise back to the JSHandle of its TaskHolder +export const eager_task_handle_symbol = Symbol.for("wasm eager_task_handle"); export function mono_wasm_get_jsobj_from_js_handle (js_handle: JSHandle): any { diff --git a/src/mono/browser/runtime/marshal-to-js.ts b/src/mono/browser/runtime/marshal-to-js.ts index 0036d4a0ef33cf..c7fb7976116e0b 100644 --- a/src/mono/browser/runtime/marshal-to-js.ts +++ b/src/mono/browser/runtime/marshal-to-js.ts @@ -6,7 +6,7 @@ import BuildConfiguration from "consts:configuration"; import WasmEnableJsInteropByValue from "consts:wasmEnableJsInteropByValue"; import cwraps from "./cwraps"; -import { _lookup_js_owned_object, mono_wasm_get_js_handle, mono_wasm_get_jsobj_from_js_handle, SystemInteropJS_ReleaseCSOwnedObject, register_with_jsv_handle, setup_managed_proxy, teardown_managed_proxy } from "./gc-handles"; +import { _lookup_js_owned_object, mono_wasm_get_js_handle, mono_wasm_get_jsobj_from_js_handle, SystemInteropJS_ReleaseCSOwnedObject, register_with_jsv_handle, setup_managed_proxy, teardown_managed_proxy, eager_task_handle_symbol } from "./gc-handles"; import { loaderHelpers, mono_assert } from "./globals"; import { ManagedObject, ManagedError, @@ -256,9 +256,21 @@ export function begin_marshal_task_to_js (arg: JSMarshalerArgument, _?: Marshale } set_js_handle(arg, js_handle); set_arg_type(arg, MarshalerType.TaskPreCreated); + // the caller only gets the promise back, so it needs a way to find the handle again. + // storing the number rather than the holder keeps the promise from retaining it. + (holder.promise as any)[eager_task_handle_symbol] = js_handle; return holder.promise; } +// the eagerly created Promise was never handed to managed code, drop its proxy +export function release_eager_task_holder (eagerPromise: Promise | null | undefined): void { + if (!eagerPromise) return; + const js_handle = (eagerPromise as any)[eager_task_handle_symbol]; + mono_assert(js_handle, "Expected JSHandle on the eagerly created promise"); + (eagerPromise as any)[eager_task_handle_symbol] = undefined; + SystemInteropJS_ReleaseCSOwnedObject(js_handle); +} + export function end_marshal_task_to_js (args: JSMarshalerArguments, res_converter: MarshalerToJs | undefined, eagerPromise: Promise | null) { // this path is used when Task is returned from JSExport/call_entry_point const res = get_arg(args, 1); @@ -270,8 +282,7 @@ export function end_marshal_task_to_js (args: JSMarshalerArguments, res_converte } // otherwise drop the eagerPromise's handle - const js_handle = mono_wasm_get_js_handle(eagerPromise); - SystemInteropJS_ReleaseCSOwnedObject(js_handle); + release_eager_task_holder(eagerPromise); // get the synchronous result const promise = try_marshal_sync_task_to_js(res, type, res_converter); From cb8bd660c3239f6859283bb2f3c528c75ca50450 Mon Sep 17 00:00:00 2001 From: pavelsavara Date: Wed, 16 Sep 2026 17:22:40 +0200 Subject: [PATCH 03/14] [browser] release the PromiseHolder when an async JSImport fails before JS adopts it An async JSImport pre-creates a PromiseHolder and its GCHandle before calling into JS. If JS threw, or returned without producing a promise, nothing freed the holder. The holder is now registered on creation, released when the call fails or leaves the slot empty, and unregistered on both release paths. Dispose no longer assumes a callback, since a pre-created holder has none until JS adopts it. --- .../JavaScript/JSFunctionBinding.cs | 19 ++++++++++++++++--- .../JavaScript/JSProxyContext.cs | 9 +++++++-- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/libraries/System.Runtime.InteropServices.JavaScript/src/System/Runtime/InteropServices/JavaScript/JSFunctionBinding.cs b/src/libraries/System.Runtime.InteropServices.JavaScript/src/System/Runtime/InteropServices/JavaScript/JSFunctionBinding.cs index 9375b83b013401..2ce511d1b9703f 100644 --- a/src/libraries/System.Runtime.InteropServices.JavaScript/src/System/Runtime/InteropServices/JavaScript/JSFunctionBinding.cs +++ b/src/libraries/System.Runtime.InteropServices.JavaScript/src/System/Runtime/InteropServices/JavaScript/JSFunctionBinding.cs @@ -297,10 +297,12 @@ internal static unsafe void InvokeJSImportImpl(JSFunctionBinding signature, Span var targetContext = JSProxyContext.MainThreadContext; #endif + JSHostImplementation.PromiseHolder? preCreatedHolder = null; if (signature.IsAsync) { // pre-allocate the result handle and Task var holder = targetContext.CreatePromiseHolder(); + preCreatedHolder = holder; res.slot.Type = MarshalerType.TaskPreCreated; res.slot.GCHandle = holder.GCHandle; #if FEATURE_WASM_MANAGED_THREADS @@ -347,15 +349,26 @@ internal static unsafe void InvokeJSImportImpl(JSFunctionBinding signature, Span DispatchJSImportSyncSend(signature, targetContext, arguments); } #else - InvokeJSImportCurrent(signature, arguments); + try + { + InvokeJSImportCurrent(signature, arguments); + } + catch + { + // JS threw before it could take ownership of the pre-created holder + if (preCreatedHolder != null) + { + targetContext.ReleasePromiseHolder(preCreatedHolder.GCHandle); + } + throw; + } if (signature.IsAsync) { // if js synchronously returned null if (arguments[1].slot.Type == MarshalerType.None) { - var holderHandle = (GCHandle)arguments[1].slot.GCHandle; - holderHandle.Free(); + targetContext.ReleasePromiseHolder(preCreatedHolder!.GCHandle); } } #endif diff --git a/src/libraries/System.Runtime.InteropServices.JavaScript/src/System/Runtime/InteropServices/JavaScript/JSProxyContext.cs b/src/libraries/System.Runtime.InteropServices.JavaScript/src/System/Runtime/InteropServices/JavaScript/JSProxyContext.cs index 07c613ee937ce1..d85a2e22999bd0 100644 --- a/src/libraries/System.Runtime.InteropServices.JavaScript/src/System/Runtime/InteropServices/JavaScript/JSProxyContext.cs +++ b/src/libraries/System.Runtime.InteropServices.JavaScript/src/System/Runtime/InteropServices/JavaScript/JSProxyContext.cs @@ -340,7 +340,9 @@ public PromiseHolder CreatePromiseHolder() lock (this) #endif { - return new PromiseHolder(this); + var holder = new PromiseHolder(this); + ThreadJsOwnedHolders.Add(holder.GCHandle, holder); + return holder; } } @@ -394,6 +396,7 @@ public void ReleasePromiseHolder(nint holderGCHandle) { throw new InvalidOperationException("ReleasePromiseHolder expected PromiseHolder" + holderGCHandle); } + ThreadJsOwnedHolders.Remove(holderGCHandle); holder.IsDisposed = true; handle.Free(); } @@ -429,6 +432,7 @@ public unsafe void ReleaseJSOwnedObjectByGCHandle(nint gcHandle) if (target is PromiseHolder holder2) { holder = holder2; + ThreadJsOwnedHolders.Remove(gcHandle); } else { @@ -565,7 +569,8 @@ private void Dispose(bool disposing) { unsafe { - holder.Callback!.Invoke(null); + // a pre-created holder has no callback until JS adopts it + holder.Callback?.Invoke(null); } ((GCHandle)holder.GCHandle).Free(); } From 765a993cb3a1c788ac4ad5e5537a32b773c582fd Mon Sep 17 00:00:00 2001 From: pavelsavara Date: Wed, 16 Sep 2026 17:23:07 +0200 Subject: [PATCH 04/14] [browser] add proxy leak tests for async marshaling ProxyLeakTest pins the JSHandle tables to their baseline across all four Task/Promise crossings, for completed and pending results and for both observed and abandoned ones, so a missed release on any non-normal path shows up as a growing table. Chromium only: draining a proxy needs a forced JS collection, and globalThis.gc is exposed by the --expose-gc argument this project passes for Chrome. Only the JSHandle tables are asserted on; the GCHandle table behind them is drained by the FinalizationRegistry a few entries per turn, so it lags by an unbounded amount and would make the assertions fragile rather than stricter. --- ...me.InteropServices.JavaScript.Tests.csproj | 1 + .../JavaScript/JavaScriptTestHelper.cs | 89 ++++++++ .../JavaScript/JavaScriptTestHelper.mjs | 58 ++++++ .../JavaScript/ProxyLeakTest.cs | 191 ++++++++++++++++++ 4 files changed, 339 insertions(+) create mode 100644 src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System/Runtime/InteropServices/JavaScript/ProxyLeakTest.cs 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 d5a40b9c28f982..9a3828c825ab73 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 @@ -34,6 +34,7 @@ + diff --git a/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System/Runtime/InteropServices/JavaScript/JavaScriptTestHelper.cs b/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System/Runtime/InteropServices/JavaScript/JavaScriptTestHelper.cs index 531e56179ea5f5..4953bc241f40f4 100644 --- a/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System/Runtime/InteropServices/JavaScript/JavaScriptTestHelper.cs +++ b/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System/Runtime/InteropServices/JavaScript/JavaScriptTestHelper.cs @@ -575,6 +575,75 @@ internal static Task ReturnCompletedTask() return Task.CompletedTask; } + [JSExport] + internal static Task ReturnCompletedTaskOfInt() + { + return Task.FromResult(42); + } + + [JSExport] + internal static Task ReturnFaultedTask() + { + return Task.FromException(new ArgumentException("ReturnFaultedTask")); + } + + [JSExport] + internal static void ReturnVoidSynchronously() + { + } + + [JSExport] + internal static async Task ReturnGenuinelyAsyncTask() + { + await Task.Yield(); + } + + [JSExport] + internal static async Task ReturnDelayedTaskOfInt() + { + await Task.Delay(1); + return 42; + } + + [JSExport] + internal static async Task ReturnDelayedFaultedTask() + { + await Task.Delay(1); + throw new ArgumentException(nameof(ReturnDelayedFaultedTask)); + } + + private static readonly List> s_pendingExports = new(); + + // hands JS a distinct Task that stays pending until CompletePendingExports settles them all + [JSExport] + internal static Task ReturnPendingTaskOfInt() + { + var tcs = new TaskCompletionSource(); + s_pendingExports.Add(tcs); + return tcs.Task; + } + + internal static void CompletePendingExports() + { + foreach (var tcs in s_pendingExports) + { + tcs.TrySetResult(42); + } + s_pendingExports.Clear(); + } + + [JSExport] + internal static async Task AwaitPromiseParameter([JSMarshalAs>] Task arg1) + { + await arg1; + } + + // the managed side abandons the Task without ever observing it + [JSExport] + internal static void IgnorePromiseParameter([JSMarshalAs>] Task arg1) + { + } + [JSExport] [return: JSMarshalAs>] public static async Task AwaitTaskOfObject([JSMarshalAs>] Task arg1) @@ -1226,6 +1295,26 @@ public static JSObject EchoIJSObject([JSMarshalAs] JSObject arg1) [JSImport("INTERNAL.forceDisposeProxies")] internal static partial void ForceDisposeProxies(bool disposeMethods, bool verbose); + // [csOwnedByJsHandle, csOwnedByJsvHandle, jsOwnedRegistered, jsOwnedAlive, importWrappers] + [JSImport("INTERNAL.getProxyCensus")] + internal static partial int[] GetProxyCensus(); + + [JSImport("forceJsGc", "JavaScriptTestHelper")] + internal static partial void ForceJsGc(); + + // mode is "await", "catch" or "drop" + [JSImport("invokeExportAsyncNTimes", "JavaScriptTestHelper")] + internal static partial Task InvokeExportAsyncNTimes(string exportName, int count, string mode); + + [JSImport("invokeExportWithPromiseNTimes", "JavaScriptTestHelper")] + internal static partial Task InvokeExportWithPromiseNTimes(string exportName, int count, bool settled); + + [JSImport("dropArg", "JavaScriptTestHelper")] + internal static partial void DropTask([JSMarshalAs>] Task arg1); + + [JSImport("tryGetAssemblyExports", "JavaScriptTestHelper")] + internal static partial Task TryGetAssemblyExports(string assemblyName); + static JSObject _module; public static async Task InitializeAsync() { diff --git a/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System/Runtime/InteropServices/JavaScript/JavaScriptTestHelper.mjs b/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System/Runtime/InteropServices/JavaScript/JavaScriptTestHelper.mjs index 1c91dbdb1dfe6b..cede597029fb9d 100644 --- a/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System/Runtime/InteropServices/JavaScript/JavaScriptTestHelper.mjs +++ b/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System/Runtime/InteropServices/JavaScript/JavaScriptTestHelper.mjs @@ -291,6 +291,64 @@ export async function invokeReturnCompletedTask() { return "resolved"; } +function resolveExport(exportName) { + const fn = dllExports.System.Runtime.InteropServices.JavaScript.Tests.JavaScriptTestHelper[exportName]; + if (typeof fn !== "function") throw new Error(`No such export ${exportName}`); + return fn; +} + +// calls a [JSExport] returning a Task. "drop" is the fire-and-forget shape reported in +// dotnet/runtime#132966, "catch" swallows the rejection without keeping the promise, +// "await" observes it. +export async function invokeExportAsyncNTimes(exportName, count, mode) { + const fn = resolveExport(exportName); + const observed = []; + for (let i = 0; i < count; i++) { + const res = fn(); + const thenable = res && typeof res.then === "function"; + if (mode === "await" && thenable) { + observed.push(res.then(() => { }, () => { })); + } else if (mode === "catch" && thenable) { + res.then(() => { }, () => { }); + } + } + await Promise.all(observed); +} + +// passes a JS promise into a [JSExport] whose parameter is a Task +export async function invokeExportWithPromiseNTimes(exportName, count, settled) { + const fn = resolveExport(exportName); + const observed = []; + for (let i = 0; i < count; i++) { + const arg = settled ? Promise.resolve(42) : delay(1).then(() => 42); + const res = fn(arg); + if (res && typeof res.then === "function") { + observed.push(res.then(() => { }, () => { })); + } + } + await Promise.all(observed); +} + +// counterpart of thenvoid: JS never observes the promise it was handed +export function dropArg(arg1) { +} + +export async function tryGetAssemblyExports(assemblyName) { + try { + await App.runtime.getAssemblyExports(assemblyName); + return "resolved"; + } catch (ex) { + return "" + ex; + } +} + +// requires --expose-gc, which this test project passes via WasmXHarnessArgs +export function forceJsGc() { + if (typeof globalThis.gc === "function") { + globalThis.gc(); + } +} + export function invokeFuncWithOffset(fn, arg, offset) { return fn(arg + offset); } diff --git a/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System/Runtime/InteropServices/JavaScript/ProxyLeakTest.cs b/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System/Runtime/InteropServices/JavaScript/ProxyLeakTest.cs new file mode 100644 index 00000000000000..7fac824f6f80f4 --- /dev/null +++ b/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System/Runtime/InteropServices/JavaScript/ProxyLeakTest.cs @@ -0,0 +1,191 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Threading.Tasks; +using Xunit; + +namespace System.Runtime.InteropServices.JavaScript.Tests +{ + // Every async marshaling path eagerly allocates one half of a Task/Promise pair before it knows + // whether the other half will ever arrive. These tests pin the JSHandle tables to their baseline + // across all four crossings, for both completion states and for observed as well as abandoned + // results, so that a missed release on any non-normal path shows up as a growing table. + // + // Chromium only: draining a proxy requires forcing a JS collection, and globalThis.gc is exposed + // by the --expose-gc engine argument this project passes for Chrome. Elsewhere the proxies are + // released on the engine's own schedule and the counts would not settle within a test. + [ConditionalClass(typeof(PlatformDetection), nameof(PlatformDetection.IsChromium))] + public class ProxyLeakTest : JSInteropTestBase, IAsyncLifetime + { + private const int Iterations = 100; + + // Drains proxies whose peer is already unreachable on either side, so that only genuinely + // rooted proxies remain counted. WaitForPendingFinalizers is a no-op on single-threaded wasm, + // so finalizers are driven by yielding between collections. + private static async Task Quiesce() + { + for (int i = 0; i < 3; i++) + { + await Task.Yield(); + await JavaScriptTestHelper.Delay(1); + JavaScriptTestHelper.ForceJsGc(); + GC.Collect(); + await Task.Yield(); + GC.Collect(); + } + } + + // run is invoked with the number of round trips to perform. + private static async Task AssertNoLeak(Func run) + { + // warm up the bindings so that their one-time allocations are not counted + await run(1); + await Quiesce(); + + int[] before = JavaScriptTestHelper.GetProxyCensus(); + await run(Iterations); + await Quiesce(); + int[] after = JavaScriptTestHelper.GetProxyCensus(); + + // Only the JSHandle tables are asserted on. They are maintained by explicit release + // calls, which is precisely where a missed release shows up, and they move only in + // response to this test. The GCHandle table behind them is drained by the JS + // FinalizationRegistry a few entries per turn, so it lags by an unbounded amount and + // would make these assertions fragile rather than stricter. + string census = "[csOwnedByJsHandle, csOwnedByJsvHandle, jsOwnedRegistered, jsOwnedAlive, importWrappers]" + + $"{Environment.NewLine}before: {string.Join(", ", before)}" + + $"{Environment.NewLine}after: {string.Join(", ", after)}"; + Assert.True(before[0] == after[0] && before[1] == after[1], census); + } + + // managed Task -> JS Promise, as the return value of a [JSExport] + // https://github.com/dotnet/runtime/issues/132966 + [Theory] + [InlineData(nameof(JavaScriptTestHelper.ReturnCompletedTask), "drop")] + [InlineData(nameof(JavaScriptTestHelper.ReturnCompletedTask), "await")] + [InlineData(nameof(JavaScriptTestHelper.ReturnCompletedTaskOfInt), "drop")] + [InlineData(nameof(JavaScriptTestHelper.ReturnCompletedTaskOfInt), "await")] + [InlineData(nameof(JavaScriptTestHelper.ReturnFaultedTask), "catch")] + [InlineData(nameof(JavaScriptTestHelper.ReturnGenuinelyAsyncTask), "drop")] + [InlineData(nameof(JavaScriptTestHelper.ReturnGenuinelyAsyncTask), "await")] + [InlineData(nameof(JavaScriptTestHelper.ReturnDelayedTaskOfInt), "drop")] + [InlineData(nameof(JavaScriptTestHelper.ReturnDelayedTaskOfInt), "await")] + [InlineData(nameof(JavaScriptTestHelper.ReturnDelayedFaultedTask), "catch")] + [InlineData(nameof(JavaScriptTestHelper.ReturnVoidSynchronously), "drop")] + public Task JSExportReturningTask_DoesNotLeakProxies(string exportName, string mode) + => AssertNoLeak(count => JavaScriptTestHelper.InvokeExportAsyncNTimes(exportName, count, mode)); + + // a promise JS abandons while it is still pending must be released once the Task completes + [Fact] + public Task JSExportReturningPendingTask_ReleasesProxiesOnCompletion() + => AssertNoLeak(async count => + { + await JavaScriptTestHelper.InvokeExportAsyncNTimes(nameof(JavaScriptTestHelper.ReturnPendingTaskOfInt), count, "drop"); + JavaScriptTestHelper.CompletePendingExports(); + }); + + // JS Promise -> managed Task, as the return value of a [JSImport] + [Theory] + [InlineData("resolved", true)] + [InlineData("resolved", false)] + [InlineData("delayed", true)] + [InlineData("delayed", false)] + [InlineData("rejected", true)] + [InlineData("rejected", false)] + public Task JSImportReturningPromise_DoesNotLeakProxies(string kind, bool observed) + => AssertNoLeak(async count => + { + var started = new List(count); + for (int i = 0; i < count; i++) + { + Task task = kind switch + { + "resolved" => JavaScriptTestHelper.ReturnResolvedPromise(), + "delayed" => JavaScriptTestHelper.sleep(1), + _ => JavaScriptTestHelper.Reject("intentionally orphaned"), + }; + + if (observed) + { + started.Add(task); + } + } + + foreach (Task task in started) + { + try + { + await task; + } + catch (JSException) + { + } + } + + // give the abandoned ones a chance to settle before the census is taken + await JavaScriptTestHelper.Delay(10); + }); + + // managed Task -> JS Promise, as an argument of a [JSImport] + [Theory] + [InlineData(true, true)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(false, false)] + public Task JSImportWithTaskArgument_DoesNotLeakProxies(bool completed, bool observedByJs) + => AssertNoLeak(async count => + { + var pending = new List(count); + for (int i = 0; i < count; i++) + { + // a fresh source every time, so that each iteration marshals a distinct Task + var tcs = new TaskCompletionSource(); + if (completed) + { + tcs.SetResult(); + } + else + { + pending.Add(tcs); + } + + if (observedByJs) + { + JavaScriptTestHelper.thenvoid(tcs.Task); + } + else + { + JavaScriptTestHelper.DropTask(tcs.Task); + } + } + + foreach (TaskCompletionSource tcs in pending) + { + tcs.SetResult(); + } + + await JavaScriptTestHelper.Delay(10); + }); + + // JS Promise -> managed Task, as an argument of a [JSExport] + [Theory] + [InlineData(nameof(JavaScriptTestHelper.AwaitPromiseParameter), true)] + [InlineData(nameof(JavaScriptTestHelper.AwaitPromiseParameter), false)] + [InlineData(nameof(JavaScriptTestHelper.IgnorePromiseParameter), true)] + [InlineData(nameof(JavaScriptTestHelper.IgnorePromiseParameter), false)] + public Task JSExportWithPromiseArgument_DoesNotLeakProxies(string exportName, bool settled) + => AssertNoLeak(count => JavaScriptTestHelper.InvokeExportWithPromiseNTimes(exportName, count, settled)); + + [Fact] + public Task FailingGetAssemblyExports_DoesNotLeakProxies() + => AssertNoLeak(async count => + { + for (int i = 0; i < count; i++) + { + string result = await JavaScriptTestHelper.TryGetAssemblyExports("System.Runtime.InteropServices.JavaScript.Tests.NoSuchAssembly"); + Assert.DoesNotContain("resolved", result); + } + }); + } +} From 52ac650ed8d2a15295c2cfbb3b8848600eb0b221 Mon Sep 17 00:00:00 2001 From: pavelsavara Date: Thu, 17 Sep 2026 12:19:04 +0200 Subject: [PATCH 05/14] [browser][mono] expose the proxy census used by the leak tests ProxyLeakTest is built for both runtime flavors, but INTERNAL.getProxyCensus existed only in the CoreCLR interop tree, so every case in the class failed on Mono with 'getProxyCensus must be a Function but was undefined'. Mirror it over the Mono proxy tables. --- src/mono/browser/runtime/exports-internal.ts | 9 +-- src/mono/browser/runtime/gc-handles.ts | 60 ++++++++++++++------ 2 files changed, 48 insertions(+), 21 deletions(-) diff --git a/src/mono/browser/runtime/exports-internal.ts b/src/mono/browser/runtime/exports-internal.ts index 174bf360de612e..1a99bba9ed381d 100644 --- a/src/mono/browser/runtime/exports-internal.ts +++ b/src/mono/browser/runtime/exports-internal.ts @@ -18,20 +18,21 @@ import { getOptions, applyOptions } from "./jiterpreter-support"; import { mono_wasm_gc_lock, mono_wasm_gc_unlock } from "./gc-lock"; import { loadLazyAssembly } from "./lazyLoading"; import { loadSatelliteAssemblies } from "./satelliteAssemblies"; -import { forceDisposeProxies } from "./gc-handles"; +import { forceDisposeProxies, get_proxy_census } from "./gc-handles"; import { mono_wasm_get_func_id_to_name_mappings } from "./logging"; import { monoStringToStringUnsafe } from "./strings"; import { mono_wasm_bind_cs_function } from "./invoke-cs"; import { mono_wasm_dump_threads } from "./pthreads"; -export function export_internal (): any { +export function export_internal(): any { return { // tests mono_wasm_exit: (exit_code: number) => { Module.err("early exit " + exit_code); }, forceDisposeProxies, + getProxyCensus: get_proxy_census, mono_wasm_dump_threads: WasmEnableThreads ? mono_wasm_dump_threads : undefined, // with mono_wasm_debugger_log and mono_wasm_trace_logger @@ -114,7 +115,7 @@ export function export_internal (): any { }; } -export function cwraps_internal (internal: any): void { +export function cwraps_internal(internal: any): void { Object.assign(internal, { mono_wasm_exit: cwraps.mono_wasm_exit, mono_wasm_profiler_init_aot: profiler_c_functions.mono_wasm_profiler_init_aot, @@ -125,7 +126,7 @@ export function cwraps_internal (internal: any): void { } /* @deprecated not GC safe, legacy support for Blazor */ -export function monoObjectAsBoolOrNullUnsafe (obj: MonoObject): boolean | null { +export function monoObjectAsBoolOrNullUnsafe(obj: MonoObject): boolean | null { // TODO https://github.com/dotnet/runtime/issues/100411 // after Blazor stops using monoObjectAsBoolOrNullUnsafe diff --git a/src/mono/browser/runtime/gc-handles.ts b/src/mono/browser/runtime/gc-handles.ts index cb63692fc50fc6..43860776b0368a 100644 --- a/src/mono/browser/runtime/gc-handles.ts +++ b/src/mono/browser/runtime/gc-handles.ts @@ -31,24 +31,24 @@ let _next_gcv_handle = -2; // GCVHandle is like GCHandle, but it's not tracked and allocated by the mono GC, but just by JS. // It's used when we need to create GCHandle-like identity ahead of time, before calling Mono. // they have negative values, so that they don't collide with GCHandles. -export function alloc_gcv_handle (): GCHandle { +export function alloc_gcv_handle(): GCHandle { const gcv_handle = _gcv_handle_free_list.length ? _gcv_handle_free_list.pop() : _next_gcv_handle--; return gcv_handle as any; } -export function free_gcv_handle (gcv_handle: GCHandle): void { +export function free_gcv_handle(gcv_handle: GCHandle): void { _gcv_handle_free_list.push(gcv_handle); } -export function is_jsv_handle (js_handle: JSHandle): boolean { +export function is_jsv_handle(js_handle: JSHandle): boolean { return (js_handle as any) < -1; } -export function is_js_handle (js_handle: JSHandle): boolean { +export function is_js_handle(js_handle: JSHandle): boolean { return (js_handle as any) > 0; } -export function is_gcv_handle (gc_handle: GCHandle): boolean { +export function is_gcv_handle(gc_handle: GCHandle): boolean { return (gc_handle as any) < -1; } @@ -64,7 +64,7 @@ export const do_not_force_dispose = Symbol.for("wasm do_not_force_dispose"); export const eager_task_handle_symbol = Symbol.for("wasm eager_task_handle"); -export function mono_wasm_get_jsobj_from_js_handle (js_handle: JSHandle): any { +export function mono_wasm_get_jsobj_from_js_handle(js_handle: JSHandle): any { if (is_js_handle(js_handle)) return _cs_owned_objects_by_js_handle[js_handle]; if (is_jsv_handle(js_handle)) @@ -72,7 +72,7 @@ export function mono_wasm_get_jsobj_from_js_handle (js_handle: JSHandle): any { return null; } -export function mono_wasm_get_js_handle (js_obj: any): JSHandle { +export function mono_wasm_get_js_handle(js_obj: any): JSHandle { assert_js_interop(); if (js_obj[cs_owned_js_handle_symbol]) { return js_obj[cs_owned_js_handle_symbol]; @@ -96,7 +96,7 @@ export function mono_wasm_get_js_handle (js_obj: any): JSHandle { return js_handle as JSHandle; } -export function register_with_jsv_handle (js_obj: any, jsv_handle: JSHandle) { +export function register_with_jsv_handle(js_obj: any, jsv_handle: JSHandle) { assert_js_interop(); // note _cs_owned_objects_by_js_handle is list, not Map. That's why we maintain _js_handle_free_list. _cs_owned_objects_by_jsv_handle[0 - jsv_handle] = js_obj; @@ -107,7 +107,7 @@ export function register_with_jsv_handle (js_obj: any, jsv_handle: JSHandle) { } // note: in MT, this is called from locked JSProxyContext. Don't call anything that would need locking. -export function SystemInteropJS_ReleaseCSOwnedObject (js_handle: JSHandle): void { +export function SystemInteropJS_ReleaseCSOwnedObject(js_handle: JSHandle): void { let obj: any; if (is_js_handle(js_handle)) { obj = _cs_owned_objects_by_js_handle[js_handle]; @@ -124,7 +124,7 @@ export function SystemInteropJS_ReleaseCSOwnedObject (js_handle: JSHandle): void } } -export function setup_managed_proxy (owner: any, gc_handle: GCHandle): void { +export function setup_managed_proxy(owner: any, gc_handle: GCHandle): void { assert_js_interop(); // keep the gc_handle so that we could easily convert it back to original C# object for roundtrip owner[js_owned_gc_handle_symbol] = gc_handle; @@ -141,7 +141,7 @@ export function setup_managed_proxy (owner: any, gc_handle: GCHandle): void { _js_owned_object_table.set(gc_handle, wr); } -export function upgrade_managed_proxy_to_strong_ref (owner: any, gc_handle: GCHandle): void { +export function upgrade_managed_proxy_to_strong_ref(owner: any, gc_handle: GCHandle): void { const sr = create_strong_ref(owner); if (_use_finalization_registry) { _js_owned_object_registry.unregister(owner); @@ -149,7 +149,7 @@ export function upgrade_managed_proxy_to_strong_ref (owner: any, gc_handle: GCHa _js_owned_object_table.set(gc_handle, sr); } -export function teardown_managed_proxy (owner: any, gc_handle: GCHandle, skipManaged?: boolean): void { +export function teardown_managed_proxy(owner: any, gc_handle: GCHandle, skipManaged?: boolean): void { assert_js_interop(); // The JS object associated with this gc_handle has been collected by the JS GC. // As such, it's not possible for this gc_handle to be invoked by JS anymore, so @@ -173,13 +173,13 @@ export function teardown_managed_proxy (owner: any, gc_handle: GCHandle, skipMan } } -export function assert_not_disposed (result: any): GCHandle { +export function assert_not_disposed(result: any): GCHandle { const gc_handle = result[js_owned_gc_handle_symbol]; mono_check(gc_handle != GCHandleNull, "ObjectDisposedException"); return gc_handle; } -function _js_owned_object_finalized (gc_handle: GCHandle): void { +function _js_owned_object_finalized(gc_handle: GCHandle): void { if (!loaderHelpers.is_runtime_running()) { // We're shutting down, so don't bother doing anything else. return; @@ -187,7 +187,33 @@ function _js_owned_object_finalized (gc_handle: GCHandle): void { teardown_managed_proxy(null, gc_handle); } -export function _lookup_js_owned_object (gc_handle: GCHandle): any { +// Counts of live proxies, for leak diagnostics and tests. Exposed as INTERNAL.getProxyCensus. +// Order: [csOwnedByJsHandle, csOwnedByJsvHandle, jsOwnedRegistered, jsOwnedAlive, importWrappers] +export function get_proxy_census(): number[] { + // index 0 of each list is always a dummy + const count_live = (list: any[]): number => { + let live = 0; + for (let i = 1; i < list.length; i++) { + if (list[i] !== undefined && list[i] !== null) live++; + } + return live; + }; + + let js_owned_alive = 0; + for (const wr of _js_owned_object_table.values()) { + if (wr.deref() !== undefined) js_owned_alive++; + } + + return [ + count_live(_cs_owned_objects_by_js_handle), + count_live(_cs_owned_objects_by_jsv_handle), + _js_owned_object_table.size, + js_owned_alive, + count_live(js_import_wrapper_by_fn_handle), + ]; +} + +export function _lookup_js_owned_object(gc_handle: GCHandle): any { if (!gc_handle) return null; const wr = _js_owned_object_table.get(gc_handle); @@ -199,7 +225,7 @@ export function _lookup_js_owned_object (gc_handle: GCHandle): any { return null; } -export function assertNoProxies (): void { +export function assertNoProxies(): void { if (!WasmEnableThreads) return; mono_assert(_js_owned_object_table.size === 0, "There should be no proxies on this thread."); mono_assert(_cs_owned_objects_by_js_handle.length === 1, "There should be no proxies on this thread."); @@ -212,7 +238,7 @@ let force_dispose_proxies_in_progress = false; // when we arrive here from UninstallWebWorkerInterop, the C# will unregister the handles too. // when called from elsewhere, C# side could be unbalanced!! -export function forceDisposeProxies (disposeMethods: boolean, verbose: boolean): void { +export function forceDisposeProxies(disposeMethods: boolean, verbose: boolean): void { let keepSomeCsAlive = false; let keepSomeJsAlive = false; force_dispose_proxies_in_progress = true; From a1c5c7a3442473994afb1ce5ff24a4f84619010f Mon Sep 17 00:00:00 2001 From: pavelsavara Date: Thu, 17 Sep 2026 12:19:24 +0200 Subject: [PATCH 06/14] [browser] restrict the proxy leak tests to a single-threaded runtime With managed threads the census also counts proxies owned by other threads, which drain independently of the test and made a run fail on a count that had gone down rather than up, and getAssemblyExports never settles. Gate the class on IsNotMultithreadingSupported, and assert on growth rather than equality so that an unrelated drain cannot fail a test whose contract is only that a round trip must not add a proxy. --- .../InteropServices/JavaScript/ProxyLeakTest.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System/Runtime/InteropServices/JavaScript/ProxyLeakTest.cs b/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System/Runtime/InteropServices/JavaScript/ProxyLeakTest.cs index 7fac824f6f80f4..c60d7c4ab855b2 100644 --- a/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System/Runtime/InteropServices/JavaScript/ProxyLeakTest.cs +++ b/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System/Runtime/InteropServices/JavaScript/ProxyLeakTest.cs @@ -15,7 +15,10 @@ namespace System.Runtime.InteropServices.JavaScript.Tests // Chromium only: draining a proxy requires forcing a JS collection, and globalThis.gc is exposed // by the --expose-gc engine argument this project passes for Chrome. Elsewhere the proxies are // released on the engine's own schedule and the counts would not settle within a test. - [ConditionalClass(typeof(PlatformDetection), nameof(PlatformDetection.IsChromium))] + // + // Single-threaded only: with managed threads the census also counts proxies held by other + // threads, which drain independently of this test, and getAssemblyExports never settles. + [ConditionalClass(typeof(PlatformDetection), nameof(PlatformDetection.IsChromium), nameof(PlatformDetection.IsNotMultithreadingSupported))] public class ProxyLeakTest : JSInteropTestBase, IAsyncLifetime { private const int Iterations = 100; @@ -53,10 +56,13 @@ private static async Task AssertNoLeak(Func run) // response to this test. The GCHandle table behind them is drained by the JS // FinalizationRegistry a few entries per turn, so it lags by an unbounded amount and // would make these assertions fragile rather than stricter. + // The contract is that a round trip must not add a proxy, so this asserts on growth + // rather than equality: an unrelated proxy draining mid-test lowers a count without + // saying anything about the path under test, while a missed release adds Iterations. string census = "[csOwnedByJsHandle, csOwnedByJsvHandle, jsOwnedRegistered, jsOwnedAlive, importWrappers]" + $"{Environment.NewLine}before: {string.Join(", ", before)}" + $"{Environment.NewLine}after: {string.Join(", ", after)}"; - Assert.True(before[0] == after[0] && before[1] == after[1], census); + Assert.True(after[0] <= before[0] && after[1] <= before[1], census); } // managed Task -> JS Promise, as the return value of a [JSExport] From a79e860cb05d6dc6771cd1e2fe4e3c42dec805ad Mon Sep 17 00:00:00 2001 From: pavelsavara Date: Thu, 17 Sep 2026 12:36:58 +0200 Subject: [PATCH 07/14] lint --- src/mono/browser/runtime/exports-internal.ts | 6 ++-- src/mono/browser/runtime/gc-handles.ts | 36 ++++++++++---------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/mono/browser/runtime/exports-internal.ts b/src/mono/browser/runtime/exports-internal.ts index 1a99bba9ed381d..dded13cf36b13b 100644 --- a/src/mono/browser/runtime/exports-internal.ts +++ b/src/mono/browser/runtime/exports-internal.ts @@ -25,7 +25,7 @@ import { mono_wasm_bind_cs_function } from "./invoke-cs"; import { mono_wasm_dump_threads } from "./pthreads"; -export function export_internal(): any { +export function export_internal (): any { return { // tests mono_wasm_exit: (exit_code: number) => { @@ -115,7 +115,7 @@ export function export_internal(): any { }; } -export function cwraps_internal(internal: any): void { +export function cwraps_internal (internal: any): void { Object.assign(internal, { mono_wasm_exit: cwraps.mono_wasm_exit, mono_wasm_profiler_init_aot: profiler_c_functions.mono_wasm_profiler_init_aot, @@ -126,7 +126,7 @@ export function cwraps_internal(internal: any): void { } /* @deprecated not GC safe, legacy support for Blazor */ -export function monoObjectAsBoolOrNullUnsafe(obj: MonoObject): boolean | null { +export function monoObjectAsBoolOrNullUnsafe (obj: MonoObject): boolean | null { // TODO https://github.com/dotnet/runtime/issues/100411 // after Blazor stops using monoObjectAsBoolOrNullUnsafe diff --git a/src/mono/browser/runtime/gc-handles.ts b/src/mono/browser/runtime/gc-handles.ts index 43860776b0368a..86c27f8e542e66 100644 --- a/src/mono/browser/runtime/gc-handles.ts +++ b/src/mono/browser/runtime/gc-handles.ts @@ -31,24 +31,24 @@ let _next_gcv_handle = -2; // GCVHandle is like GCHandle, but it's not tracked and allocated by the mono GC, but just by JS. // It's used when we need to create GCHandle-like identity ahead of time, before calling Mono. // they have negative values, so that they don't collide with GCHandles. -export function alloc_gcv_handle(): GCHandle { +export function alloc_gcv_handle (): GCHandle { const gcv_handle = _gcv_handle_free_list.length ? _gcv_handle_free_list.pop() : _next_gcv_handle--; return gcv_handle as any; } -export function free_gcv_handle(gcv_handle: GCHandle): void { +export function free_gcv_handle (gcv_handle: GCHandle): void { _gcv_handle_free_list.push(gcv_handle); } -export function is_jsv_handle(js_handle: JSHandle): boolean { +export function is_jsv_handle (js_handle: JSHandle): boolean { return (js_handle as any) < -1; } -export function is_js_handle(js_handle: JSHandle): boolean { +export function is_js_handle (js_handle: JSHandle): boolean { return (js_handle as any) > 0; } -export function is_gcv_handle(gc_handle: GCHandle): boolean { +export function is_gcv_handle (gc_handle: GCHandle): boolean { return (gc_handle as any) < -1; } @@ -64,7 +64,7 @@ export const do_not_force_dispose = Symbol.for("wasm do_not_force_dispose"); export const eager_task_handle_symbol = Symbol.for("wasm eager_task_handle"); -export function mono_wasm_get_jsobj_from_js_handle(js_handle: JSHandle): any { +export function mono_wasm_get_jsobj_from_js_handle (js_handle: JSHandle): any { if (is_js_handle(js_handle)) return _cs_owned_objects_by_js_handle[js_handle]; if (is_jsv_handle(js_handle)) @@ -72,7 +72,7 @@ export function mono_wasm_get_jsobj_from_js_handle(js_handle: JSHandle): any { return null; } -export function mono_wasm_get_js_handle(js_obj: any): JSHandle { +export function mono_wasm_get_js_handle (js_obj: any): JSHandle { assert_js_interop(); if (js_obj[cs_owned_js_handle_symbol]) { return js_obj[cs_owned_js_handle_symbol]; @@ -96,7 +96,7 @@ export function mono_wasm_get_js_handle(js_obj: any): JSHandle { return js_handle as JSHandle; } -export function register_with_jsv_handle(js_obj: any, jsv_handle: JSHandle) { +export function register_with_jsv_handle (js_obj: any, jsv_handle: JSHandle) { assert_js_interop(); // note _cs_owned_objects_by_js_handle is list, not Map. That's why we maintain _js_handle_free_list. _cs_owned_objects_by_jsv_handle[0 - jsv_handle] = js_obj; @@ -107,7 +107,7 @@ export function register_with_jsv_handle(js_obj: any, jsv_handle: JSHandle) { } // note: in MT, this is called from locked JSProxyContext. Don't call anything that would need locking. -export function SystemInteropJS_ReleaseCSOwnedObject(js_handle: JSHandle): void { +export function SystemInteropJS_ReleaseCSOwnedObject (js_handle: JSHandle): void { let obj: any; if (is_js_handle(js_handle)) { obj = _cs_owned_objects_by_js_handle[js_handle]; @@ -124,7 +124,7 @@ export function SystemInteropJS_ReleaseCSOwnedObject(js_handle: JSHandle): void } } -export function setup_managed_proxy(owner: any, gc_handle: GCHandle): void { +export function setup_managed_proxy (owner: any, gc_handle: GCHandle): void { assert_js_interop(); // keep the gc_handle so that we could easily convert it back to original C# object for roundtrip owner[js_owned_gc_handle_symbol] = gc_handle; @@ -141,7 +141,7 @@ export function setup_managed_proxy(owner: any, gc_handle: GCHandle): void { _js_owned_object_table.set(gc_handle, wr); } -export function upgrade_managed_proxy_to_strong_ref(owner: any, gc_handle: GCHandle): void { +export function upgrade_managed_proxy_to_strong_ref (owner: any, gc_handle: GCHandle): void { const sr = create_strong_ref(owner); if (_use_finalization_registry) { _js_owned_object_registry.unregister(owner); @@ -149,7 +149,7 @@ export function upgrade_managed_proxy_to_strong_ref(owner: any, gc_handle: GCHan _js_owned_object_table.set(gc_handle, sr); } -export function teardown_managed_proxy(owner: any, gc_handle: GCHandle, skipManaged?: boolean): void { +export function teardown_managed_proxy (owner: any, gc_handle: GCHandle, skipManaged?: boolean): void { assert_js_interop(); // The JS object associated with this gc_handle has been collected by the JS GC. // As such, it's not possible for this gc_handle to be invoked by JS anymore, so @@ -173,13 +173,13 @@ export function teardown_managed_proxy(owner: any, gc_handle: GCHandle, skipMana } } -export function assert_not_disposed(result: any): GCHandle { +export function assert_not_disposed (result: any): GCHandle { const gc_handle = result[js_owned_gc_handle_symbol]; mono_check(gc_handle != GCHandleNull, "ObjectDisposedException"); return gc_handle; } -function _js_owned_object_finalized(gc_handle: GCHandle): void { +function _js_owned_object_finalized (gc_handle: GCHandle): void { if (!loaderHelpers.is_runtime_running()) { // We're shutting down, so don't bother doing anything else. return; @@ -189,7 +189,7 @@ function _js_owned_object_finalized(gc_handle: GCHandle): void { // Counts of live proxies, for leak diagnostics and tests. Exposed as INTERNAL.getProxyCensus. // Order: [csOwnedByJsHandle, csOwnedByJsvHandle, jsOwnedRegistered, jsOwnedAlive, importWrappers] -export function get_proxy_census(): number[] { +export function get_proxy_census (): number[] { // index 0 of each list is always a dummy const count_live = (list: any[]): number => { let live = 0; @@ -213,7 +213,7 @@ export function get_proxy_census(): number[] { ]; } -export function _lookup_js_owned_object(gc_handle: GCHandle): any { +export function _lookup_js_owned_object (gc_handle: GCHandle): any { if (!gc_handle) return null; const wr = _js_owned_object_table.get(gc_handle); @@ -225,7 +225,7 @@ export function _lookup_js_owned_object(gc_handle: GCHandle): any { return null; } -export function assertNoProxies(): void { +export function assertNoProxies (): void { if (!WasmEnableThreads) return; mono_assert(_js_owned_object_table.size === 0, "There should be no proxies on this thread."); mono_assert(_cs_owned_objects_by_js_handle.length === 1, "There should be no proxies on this thread."); @@ -238,7 +238,7 @@ let force_dispose_proxies_in_progress = false; // when we arrive here from UninstallWebWorkerInterop, the C# will unregister the handles too. // when called from elsewhere, C# side could be unbalanced!! -export function forceDisposeProxies(disposeMethods: boolean, verbose: boolean): void { +export function forceDisposeProxies (disposeMethods: boolean, verbose: boolean): void { let keepSomeCsAlive = false; let keepSomeJsAlive = false; force_dispose_proxies_in_progress = true; From 1f46b81b3ec24f10f1f9b2e47deb254d504ec2c0 Mon Sep 17 00:00:00 2001 From: pavelsavara Date: Thu, 17 Sep 2026 20:48:34 +0200 Subject: [PATCH 08/14] [browser][mono] release the eagerly created Promise when an async JSExport throws call_entry_point and bind_assembly_exports pre-allocate a Task proxy via begin_marshal_task_to_js(TaskPreCreated) and rely on end_marshal_task_to_js to adopt or release it. When the managed call fails, invoke_async_jsexport throws on is_args_exception before either wrapper reaches end_marshal_task_to_js, so the holder stays registered under its JSHandle and the proxy leaks. This corrects the claim in the earlier Mono commit that the bindAssemblyExports leak did not exist on Mono. The wrapper has no is_args_exception branch of its own, but invoke_async_jsexport has one, which is where the throw originates. CoreCLR is unaffected because it inlines the check and releases the holder before throwing. call_entry_point has the same shape and is fixed alongside it, though no test covers a synchronously throwing entrypoint. --- src/mono/browser/runtime/managed-exports.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/mono/browser/runtime/managed-exports.ts b/src/mono/browser/runtime/managed-exports.ts index e1d2dc06cb5027..59e6e2e8e693d7 100644 --- a/src/mono/browser/runtime/managed-exports.ts +++ b/src/mono/browser/runtime/managed-exports.ts @@ -8,7 +8,7 @@ import cwraps, { threads_c_functions as twraps } from "./cwraps"; import { runtimeHelpers, Module, loaderHelpers, mono_assert } from "./globals"; import { JavaScriptMarshalerArgSize, alloc_stack_frame, get_arg, get_arg_gc_handle, is_args_exception, set_arg_i32, set_arg_intptr, set_arg_type, set_gc_handle, set_receiver_should_free } from "./marshal"; import { marshal_array_to_cs, marshal_array_to_cs_impl, marshal_bool_to_cs, marshal_exception_to_cs, marshal_intptr_to_cs, marshal_string_to_cs } from "./marshal-to-cs"; -import { marshal_int32_to_js, end_marshal_task_to_js, marshal_string_to_js, begin_marshal_task_to_js, marshal_exception_to_js } from "./marshal-to-js"; +import { marshal_int32_to_js, end_marshal_task_to_js, marshal_string_to_js, begin_marshal_task_to_js, marshal_exception_to_js, release_eager_task_holder } from "./marshal-to-js"; import { do_not_force_dispose, is_gcv_handle } from "./gc-handles"; import { assert_c_interop, assert_js_interop } from "./invoke-js"; import { monoThreadInfo, mono_wasm_main_thread_ptr } from "./pthreads"; @@ -62,7 +62,13 @@ export function call_entry_point (main_assembly_name: string, program_args: stri // because this is async, we could pre-allocate the promise let promise = begin_marshal_task_to_js(res, MarshalerType.TaskPreCreated, marshal_int32_to_js); - invoke_async_jsexport(runtimeHelpers.managedThreadTID, managedExports.CallEntrypoint, args, size); + try { + invoke_async_jsexport(runtimeHelpers.managedThreadTID, managedExports.CallEntrypoint, args, size); + } catch (ex) { + // the throw unwinds past end_marshal_task_to_js, which would otherwise adopt the promise + release_eager_task_holder(promise); + throw ex; + } // in case the C# side returned synchronously promise = end_marshal_task_to_js(args, marshal_int32_to_js, promise); @@ -343,7 +349,13 @@ export function bind_assembly_exports (assemblyName: string): Promise { // because this is async, we could pre-allocate the promise let promise = begin_marshal_task_to_js(res, MarshalerType.TaskPreCreated); - invoke_async_jsexport(runtimeHelpers.managedThreadTID, managedExports.BindAssemblyExports, args, size); + try { + invoke_async_jsexport(runtimeHelpers.managedThreadTID, managedExports.BindAssemblyExports, args, size); + } catch (ex) { + // the throw unwinds past end_marshal_task_to_js, which would otherwise adopt the promise + release_eager_task_holder(promise); + throw ex; + } // in case the C# side returned synchronously promise = end_marshal_task_to_js(args, marshal_int32_to_js, promise); From 85ace283ee2ebdc47dd713d3e4d08620284fd81c Mon Sep 17 00:00:00 2001 From: pavelsavara Date: Fri, 18 Sep 2026 16:54:05 +0200 Subject: [PATCH 09/14] skip test for mono --- .../System/Runtime/InteropServices/JavaScript/ProxyLeakTest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System/Runtime/InteropServices/JavaScript/ProxyLeakTest.cs b/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System/Runtime/InteropServices/JavaScript/ProxyLeakTest.cs index c60d7c4ab855b2..5771bd7a7fd3fb 100644 --- a/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System/Runtime/InteropServices/JavaScript/ProxyLeakTest.cs +++ b/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System/Runtime/InteropServices/JavaScript/ProxyLeakTest.cs @@ -183,7 +183,7 @@ public Task JSImportWithTaskArgument_DoesNotLeakProxies(bool completed, bool obs public Task JSExportWithPromiseArgument_DoesNotLeakProxies(string exportName, bool settled) => AssertNoLeak(count => JavaScriptTestHelper.InvokeExportWithPromiseNTimes(exportName, count, settled)); - [Fact] + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMonoRuntime))] public Task FailingGetAssemblyExports_DoesNotLeakProxies() => AssertNoLeak(async count => { From 8f18fc379d3402cfda886f2b5ca5984427f7b11a Mon Sep 17 00:00:00 2001 From: pavelsavara Date: Mon, 21 Sep 2026 18:25:45 +0200 Subject: [PATCH 10/14] [browser][mono] deliver a synchronously thrown JS import failure through the Task An async [JSImport] dispatched with DispatchJSImportAsyncPost leaves the caller awaiting the pre-created Task and returns, so nothing ever reads the exception slot that the bound wrapper writes when the JS function throws. The Task stayed pending forever: the await hung and the PromiseHolder leaked. bind_fn now delivers the failure through the Task when the caller is gone, which is what SystemInteropJS_InvokeJSImportSync already does for a bind failure. The condition is receiver_should_free plus a TaskPreCreated result slot, so the current-thread path and DiscardNoWait keep the synchronous convention. The CoreCLR tree has the same shape but no thread support, so nothing sets ReceiverShouldFree there yet. Marked with TODO-MT rather than adding a branch that cannot run. --- src/mono/browser/runtime/invoke-js.ts | 11 +++++++++-- .../interop/invoke-js.ts | 4 ++++ .../interop/marshal-to-js.ts | 2 ++ .../interop/marshal.ts | 1 + 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/mono/browser/runtime/invoke-js.ts b/src/mono/browser/runtime/invoke-js.ts index 63e6731f46b909..bcc08dc0370f7a 100644 --- a/src/mono/browser/runtime/invoke-js.ts +++ b/src/mono/browser/runtime/invoke-js.ts @@ -5,7 +5,7 @@ import WasmEnableThreads from "consts:wasmEnableThreads"; import BuildConfiguration from "consts:configuration"; import { marshal_exception_to_cs, bind_arg_marshal_to_cs, marshal_task_to_cs } from "./marshal-to-cs"; -import { get_signature_argument_count, bound_js_function_symbol, get_sig, get_signature_version, get_signature_type, imported_js_function_symbol, get_signature_handle, get_signature_function_name, get_signature_module_name, is_receiver_should_free, get_caller_native_tid, get_sync_done_semaphore_ptr, get_arg } from "./marshal"; +import { get_signature_argument_count, bound_js_function_symbol, get_sig, get_signature_version, get_signature_type, imported_js_function_symbol, get_signature_handle, get_signature_function_name, get_signature_module_name, is_receiver_should_free, get_caller_native_tid, get_sync_done_semaphore_ptr, get_arg, get_arg_type } from "./marshal"; import { fixupPointer, forceThreadMemoryViewRefresh, free } from "./memory"; import { JSFunctionSignature, JSMarshalerArguments, BoundMarshalerToJs, JSFnHandle, BoundMarshalerToCs, JSHandle, MarshalerType, VoidPtrNull } from "./types/internal"; import { VoidPtr } from "./types/emscripten"; @@ -335,7 +335,14 @@ function bind_fn (closure: BindingClosure) { } } } catch (ex) { - marshal_exception_to_cs(args, ex); + // on the async post path the caller already has the pre-created Task and is gone, so it + // would never read the exception slot. Deliver the failure through the Task instead. + const res = receiver_should_free ? get_arg(args, 1) : null; + if (res && get_arg_type(res) === MarshalerType.TaskPreCreated) { + marshal_task_to_cs(res, Promise.reject(ex)); + } else { + marshal_exception_to_cs(args, ex); + } } finally { if (receiver_should_free) { free(args as any); diff --git a/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/invoke-js.ts b/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/invoke-js.ts index e355d9e3e44157..a70928bf55f488 100644 --- a/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/invoke-js.ts +++ b/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/invoke-js.ts @@ -227,6 +227,7 @@ function bindFn(closure: BindingClosureJS) { const fqn = closure.fqn; (closure) = null; return function boundFn(args: JSMarshalerArguments) { + // TODO-MT: always false until threads are enabled, nothing sets ReceiverShouldFree yet const receiverShouldFree = isReceiverShouldFree(args); const mark = startMeasure(); try { @@ -253,6 +254,9 @@ function bindFn(closure: BindingClosureJS) { } } } catch (ex) { + // TODO-MT: once threads are enabled, an async import posted to another thread leaves the + // caller awaiting the pre-created Task and it never reads this slot, so the failure has + // to be delivered through the Task instead. See bind_fn in src/mono/browser/runtime. marshalExceptionToCs(args, ex); } finally { if (receiverShouldFree) { diff --git a/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/marshal-to-js.ts b/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/marshal-to-js.ts index f3dc7e2fe56af8..03dc3da41ef682 100644 --- a/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/marshal-to-js.ts +++ b/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/marshal-to-js.ts @@ -537,6 +537,8 @@ export function resolveOrRejectPromise(args: JSMarshalerArguments): void { } args = fixupPointer(args, 0); const exc = getArg(args, 0); + // TODO-MT: always false until threads are enabled, only the cross-thread post paths set it. + // Keep in sync with resolve_or_reject_promise in src/mono/browser/runtime. const receiverShouldFree = isReceiverShouldFree(args); try { assertRuntimeRunning(); 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 b937624441becb..c82e2134cbf0b1 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 @@ -73,6 +73,7 @@ export function getCallerNativeTid(args: JSMarshalerArguments): PThreadPtr { return dotnetApi.getHeapI32(args + JSMarshalerArgumentOffsets.CallerNativeTID) as any; } +// TODO-MT: unused until threads are enabled, the cross-thread post paths set this in the Mono tree export function setReceiverShouldFree(args: JSMarshalerArguments): void { dotnetApi.setHeapB8(args + JSMarshalerArgumentOffsets.ReceiverShouldFree, true); } From 220d08e59ee6d2fa0dd9a238b973a310cacf6a87 Mon Sep 17 00:00:00 2001 From: pavelsavara Date: Mon, 21 Sep 2026 18:26:05 +0200 Subject: [PATCH 11/14] [browser] release the pre-created PromiseHolder on the threaded paths InvokeJSImportImpl creates the holder before calling JS but only released it again on the single-threaded path. Under FEATURE_WASM_MANAGED_THREADS a throw, or a JS function returning null instead of a Promise, left the holder registered in ThreadJsOwnedHolders together with its GCHandle. JSProxyContext.Dispose frees holder.State as the other release paths already do, and walks a snapshot of the dictionary because the callback it invokes can re-enter and release a holder. Holders already taken that way are skipped, so the snapshot cannot turn a mutation into a double free. PromiseHolderCount exposes the managed half of the proxy census. A pre-created holder has no JS-side entry until JS adopts it, so a missed release is invisible to the JS-side census the other leak tests assert on. It is private and read through UnsafeAccessor, rooted from BindJSFunction because the runtime pack is trimmed when it is built. --- .../JavaScript/JSFunctionBinding.cs | 51 +++++++++++++------ .../JavaScript/JSProxyContext.cs | 28 +++++++++- .../JavaScript/JavaScriptTestHelper.cs | 3 ++ .../JavaScript/JavaScriptTestHelper.mjs | 5 ++ .../JavaScript/ProxyLeakTest.cs | 45 ++++++++++++++++ 5 files changed, 116 insertions(+), 16 deletions(-) diff --git a/src/libraries/System.Runtime.InteropServices.JavaScript/src/System/Runtime/InteropServices/JavaScript/JSFunctionBinding.cs b/src/libraries/System.Runtime.InteropServices.JavaScript/src/System/Runtime/InteropServices/JavaScript/JSFunctionBinding.cs index 2ce511d1b9703f..9f4e4a6cfcafb2 100644 --- a/src/libraries/System.Runtime.InteropServices.JavaScript/src/System/Runtime/InteropServices/JavaScript/JSFunctionBinding.cs +++ b/src/libraries/System.Runtime.InteropServices.JavaScript/src/System/Runtime/InteropServices/JavaScript/JSFunctionBinding.cs @@ -170,6 +170,8 @@ public static void InvokeJS(JSFunctionBinding signature, SpanThe method is executed on an architecture other than WebAssembly. // JavaScriptExports need to be protected from trimming because they are used from C/JS code which IL linker can't see [DynamicDependency(DynamicallyAccessedMemberTypes.PublicMethods, "System.Runtime.InteropServices.JavaScript.JavaScriptExports", "System.Runtime.InteropServices.JavaScript")] + // PromiseHolderCount has no callers here, it is read by the leak tests through UnsafeAccessor + [DynamicDependency("get_PromiseHolderCount", typeof(JSFunctionBinding))] public static JSFunctionBinding BindJSFunction(string functionName, string moduleName, ReadOnlySpan signatures) { if (RuntimeInformation.OSArchitecture != Architecture.Wasm) @@ -281,6 +283,10 @@ internal static unsafe void DispatchJSFunctionSync(JSObject jsFunction, Span JSProxyContext.AssertIsInteropThread().PromiseHolderCount; + #if !DEBUG [MethodImpl(MethodImplOptions.AggressiveInlining)] #endif @@ -325,28 +331,43 @@ internal static unsafe void InvokeJSImportImpl(JSFunctionBinding signature, Span } #if FEATURE_WASM_MANAGED_THREADS - // if we are on correct thread already or this is synchronous call, just call it - if (targetContext.IsCurrentThread()) + try { - InvokeJSImportCurrent(signature, arguments); + // if we are on correct thread already or this is synchronous call, just call it + if (targetContext.IsCurrentThread()) + { + InvokeJSImportCurrent(signature, arguments); + // if js synchronously returned null + if (signature.IsAsync && arguments[1].slot.Type == MarshalerType.None) + { + targetContext.ReleasePromiseHolder(preCreatedHolder!.GCHandle); + // cleared so the catch below does not release it a second time + preCreatedHolder = null; #if DEBUG - if (signature.IsAsync && arguments[1].slot.Type == MarshalerType.None) + throw new InvalidOperationException("null Task/Promise return is not supported"); +#endif + } + } + else if (signature.IsAsync || signature.IsDiscardNoWait) { - throw new InvalidOperationException("null Task/Promise return is not supported"); + //async + DispatchJSImportAsyncPost(signature, targetContext, arguments); + } + else + { + //sync + DispatchJSImportSyncSend(signature, targetContext, arguments); } -#endif - - } - else if (signature.IsAsync || signature.IsDiscardNoWait) - { - //async - DispatchJSImportAsyncPost(signature, targetContext, arguments); } - else + catch { - //sync - DispatchJSImportSyncSend(signature, targetContext, arguments); + // JS threw before it could take ownership of the pre-created holder + if (preCreatedHolder != null) + { + targetContext.ReleasePromiseHolder(preCreatedHolder.GCHandle); + } + throw; } #else try diff --git a/src/libraries/System.Runtime.InteropServices.JavaScript/src/System/Runtime/InteropServices/JavaScript/JSProxyContext.cs b/src/libraries/System.Runtime.InteropServices.JavaScript/src/System/Runtime/InteropServices/JavaScript/JSProxyContext.cs index d85a2e22999bd0..22c0bce7c91c08 100644 --- a/src/libraries/System.Runtime.InteropServices.JavaScript/src/System/Runtime/InteropServices/JavaScript/JSProxyContext.cs +++ b/src/libraries/System.Runtime.InteropServices.JavaScript/src/System/Runtime/InteropServices/JavaScript/JSProxyContext.cs @@ -26,6 +26,19 @@ internal sealed class JSProxyContext : IDisposable internal Dictionary> JSExportByHandle = new Dictionary>(); internal int NextJSExportHandle = 1; + public int PromiseHolderCount + { + get + { +#if FEATURE_WASM_MANAGED_THREADS + lock (this) +#endif + { + return ThreadJsOwnedHolders.Count; + } + } + } + #if !FEATURE_WASM_MANAGED_THREADS private JSProxyContext() { @@ -565,18 +578,31 @@ private void Dispose(bool disposing) GCHandle gcHandle = (GCHandle)gch; gcHandle.Free(); } - foreach (var holder in ThreadJsOwnedHolders.Values) + // the callback can re-enter and release a holder, which would mutate the + // dictionary, so walk a snapshot and skip whatever it already took + List holders = new(ThreadJsOwnedHolders.Values); + foreach (var holder in holders) { + if (holder.IsDisposed) + { + continue; + } + holder.IsDisposed = true; unsafe { // a pre-created holder has no callback until JS adopts it holder.Callback?.Invoke(null); +#if FEATURE_WASM_MANAGED_THREADS + NativeMemory.Free(holder.State); + holder.State = null; +#endif } ((GCHandle)holder.GCHandle).Free(); } ThreadCsOwnedObjects.Clear(); ThreadJsOwnedObjects.Clear(); + ThreadJsOwnedHolders.Clear(); JSVHandleFreeList.Clear(); NextJSVHandle = IntPtr.Zero; diff --git a/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System/Runtime/InteropServices/JavaScript/JavaScriptTestHelper.cs b/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System/Runtime/InteropServices/JavaScript/JavaScriptTestHelper.cs index 4953bc241f40f4..549ef98e8ef113 100644 --- a/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System/Runtime/InteropServices/JavaScript/JavaScriptTestHelper.cs +++ b/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System/Runtime/InteropServices/JavaScript/JavaScriptTestHelper.cs @@ -40,6 +40,9 @@ public static void ConsoleWriteLine([JSMarshalAs] string message) [JSImport("reject", "JavaScriptTestHelper")] public static partial Task Reject([JSMarshalAs] object what); + [JSImport("throwBeforePromise", "JavaScriptTestHelper")] + internal static partial Task ThrowBeforePromise(); + [JSImport("intentionallyMissingImport", "JavaScriptTestHelper")] public static partial void IntentionallyMissingImport(); diff --git a/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System/Runtime/InteropServices/JavaScript/JavaScriptTestHelper.mjs b/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System/Runtime/InteropServices/JavaScript/JavaScriptTestHelper.mjs index cede597029fb9d..21166782a2f681 100644 --- a/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System/Runtime/InteropServices/JavaScript/JavaScriptTestHelper.mjs +++ b/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System/Runtime/InteropServices/JavaScript/JavaScriptTestHelper.mjs @@ -565,6 +565,11 @@ export function reject(what) { return new Promise((_, reject) => globalThis.setTimeout(() => reject(what), 0)); } +// throws instead of returning a Promise, so the pre-created Task is never adopted +export function throwBeforePromise() { + throw new Error("intentionally thrown before returning a promise"); +} + let setTimeoutHit = false; let promiseThenHit = false; export function beforeYield() { diff --git a/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System/Runtime/InteropServices/JavaScript/ProxyLeakTest.cs b/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System/Runtime/InteropServices/JavaScript/ProxyLeakTest.cs index 5771bd7a7fd3fb..7af9914d766c27 100644 --- a/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System/Runtime/InteropServices/JavaScript/ProxyLeakTest.cs +++ b/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System/Runtime/InteropServices/JavaScript/ProxyLeakTest.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Generic; +using System.Runtime.CompilerServices; using System.Threading.Tasks; using Xunit; @@ -183,6 +184,9 @@ public Task JSImportWithTaskArgument_DoesNotLeakProxies(bool completed, bool obs public Task JSExportWithPromiseArgument_DoesNotLeakProxies(string exportName, bool settled) => AssertNoLeak(count => JavaScriptTestHelper.InvokeExportWithPromiseNTimes(exportName, count, settled)); + // CoreCLR only: its BindAssemblyExports marshals the failure back as a managed exception, + // while on Mono a missing assembly trips a native assert that aborts the runtime, leaving + // managed code nothing to catch. [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMonoRuntime))] public Task FailingGetAssemblyExports_DoesNotLeakProxies() => AssertNoLeak(async count => @@ -194,4 +198,45 @@ public Task FailingGetAssemblyExports_DoesNotLeakProxies() } }); } + + // Separate from ProxyLeakTest because it counts managed PromiseHolders rather than JS proxies. + // That table is per-context and released explicitly, so it needs neither a forced collection + // nor a single-threaded runtime to settle. + public class PromiseHolderLeakTest : JSInteropTestBase, IAsyncLifetime + { + private const int Iterations = 100; + + [UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name = "get_PromiseHolderCount")] + private static extern int GetPromiseHolderCount(JSFunctionBinding binding); + + private static async Task ThrowNTimes(int count) + { + for (int i = 0; i < count; i++) + { + try + { + // the JS side throws instead of returning a Promise, so the eagerly created + // holder is never handed over + await JavaScriptTestHelper.ThrowBeforePromise(); + Assert.Fail("expected the JS side to throw"); + } + catch (JSException) + { + } + } + } + + [Fact] + public async Task ThrowingAsyncImport_DoesNotLeakHolders() + { + // warm up the binding so its one-time allocations are not counted + await ThrowNTimes(1); + + int before = GetPromiseHolderCount(null); + await ThrowNTimes(Iterations); + int after = GetPromiseHolderCount(null); + + Assert.True(after <= before, $"promise holders before: {before}, after: {after}"); + } + } } From 37a313bc44eda6802f1e3ab39a545c64be1cf8a0 Mon Sep 17 00:00:00 2001 From: pavelsavara Date: Mon, 21 Sep 2026 19:36:27 +0200 Subject: [PATCH 12/14] [browser] release the eagerly created Task proxy when an async JSExport throws The async JSExport wrappers create the JS promise before invoking managed code. A synchronous managed throw is marshaled into the exception slot and rethrown out of invoke_async_jsexport, which unwinds past end_marshal_task_to_js, so the eagerly created proxy was never adopted nor released and stayed rooted in the JSHandle table. Release it on the catch path in all three wrappers, in both the Mono and CoreCLR implementations. Also guard Dispose against freeing a GCVHandle, which is a synthetic index rather than a real GCHandle, matching ReleasePromiseHolder. --- .../JavaScript/JSProxyContext.cs | 6 +++- .../JavaScript/JavaScriptTestHelper.cs | 7 +++++ .../JavaScript/JavaScriptTestHelper.mjs | 11 +++++-- .../JavaScript/ProxyLeakTest.cs | 1 + src/mono/browser/runtime/invoke-cs.ts | 30 +++++++++++++++---- .../interop/invoke-cs.ts | 30 +++++++++++++++---- 6 files changed, 70 insertions(+), 15 deletions(-) diff --git a/src/libraries/System.Runtime.InteropServices.JavaScript/src/System/Runtime/InteropServices/JavaScript/JSProxyContext.cs b/src/libraries/System.Runtime.InteropServices.JavaScript/src/System/Runtime/InteropServices/JavaScript/JSProxyContext.cs index 22c0bce7c91c08..13a090332f2d60 100644 --- a/src/libraries/System.Runtime.InteropServices.JavaScript/src/System/Runtime/InteropServices/JavaScript/JSProxyContext.cs +++ b/src/libraries/System.Runtime.InteropServices.JavaScript/src/System/Runtime/InteropServices/JavaScript/JSProxyContext.cs @@ -597,7 +597,11 @@ private void Dispose(bool disposing) holder.State = null; #endif } - ((GCHandle)holder.GCHandle).Free(); + // a GCVHandle is a synthetic index, not a real GCHandle, so it must not be freed + if (!IsGCVHandle(holder.GCHandle)) + { + ((GCHandle)holder.GCHandle).Free(); + } } ThreadCsOwnedObjects.Clear(); diff --git a/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System/Runtime/InteropServices/JavaScript/JavaScriptTestHelper.cs b/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System/Runtime/InteropServices/JavaScript/JavaScriptTestHelper.cs index 549ef98e8ef113..7e8c1723e2e5c3 100644 --- a/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System/Runtime/InteropServices/JavaScript/JavaScriptTestHelper.cs +++ b/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System/Runtime/InteropServices/JavaScript/JavaScriptTestHelper.cs @@ -590,6 +590,13 @@ internal static Task ReturnFaultedTask() return Task.FromException(new ArgumentException("ReturnFaultedTask")); } + // throws during the invocation itself, so JS never gets the Task it eagerly created for it + [JSExport] + internal static Task ThrowBeforeTask() + { + throw new ArgumentException("ThrowBeforeTask"); + } + [JSExport] internal static void ReturnVoidSynchronously() { diff --git a/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System/Runtime/InteropServices/JavaScript/JavaScriptTestHelper.mjs b/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System/Runtime/InteropServices/JavaScript/JavaScriptTestHelper.mjs index 21166782a2f681..c331cd68ce3162 100644 --- a/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System/Runtime/InteropServices/JavaScript/JavaScriptTestHelper.mjs +++ b/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System/Runtime/InteropServices/JavaScript/JavaScriptTestHelper.mjs @@ -299,12 +299,19 @@ function resolveExport(exportName) { // calls a [JSExport] returning a Task. "drop" is the fire-and-forget shape reported in // dotnet/runtime#132966, "catch" swallows the rejection without keeping the promise, -// "await" observes it. +// "await" observes it, "throws" expects the call itself to throw instead of returning a Task. export async function invokeExportAsyncNTimes(exportName, count, mode) { const fn = resolveExport(exportName); const observed = []; for (let i = 0; i < count; i++) { - const res = fn(); + let res; + try { + res = fn(); + } catch (ex) { + if (mode !== "throws") throw ex; + // there is no promise to observe, the eagerly created one had to be released + continue; + } const thenable = res && typeof res.then === "function"; if (mode === "await" && thenable) { observed.push(res.then(() => { }, () => { })); diff --git a/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System/Runtime/InteropServices/JavaScript/ProxyLeakTest.cs b/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System/Runtime/InteropServices/JavaScript/ProxyLeakTest.cs index 7af9914d766c27..9f3de0314f580c 100644 --- a/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System/Runtime/InteropServices/JavaScript/ProxyLeakTest.cs +++ b/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System/Runtime/InteropServices/JavaScript/ProxyLeakTest.cs @@ -74,6 +74,7 @@ private static async Task AssertNoLeak(Func run) [InlineData(nameof(JavaScriptTestHelper.ReturnCompletedTaskOfInt), "drop")] [InlineData(nameof(JavaScriptTestHelper.ReturnCompletedTaskOfInt), "await")] [InlineData(nameof(JavaScriptTestHelper.ReturnFaultedTask), "catch")] + [InlineData(nameof(JavaScriptTestHelper.ThrowBeforeTask), "throws")] [InlineData(nameof(JavaScriptTestHelper.ReturnGenuinelyAsyncTask), "drop")] [InlineData(nameof(JavaScriptTestHelper.ReturnGenuinelyAsyncTask), "await")] [InlineData(nameof(JavaScriptTestHelper.ReturnDelayedTaskOfInt), "drop")] diff --git a/src/mono/browser/runtime/invoke-cs.ts b/src/mono/browser/runtime/invoke-cs.ts index 3e78c48301774a..83a28a73f8b472 100644 --- a/src/mono/browser/runtime/invoke-cs.ts +++ b/src/mono/browser/runtime/invoke-cs.ts @@ -6,7 +6,7 @@ import WasmEnableThreads from "consts:wasmEnableThreads"; import { Module, loaderHelpers, mono_assert, runtimeHelpers } from "./globals"; import { bind_arg_marshal_to_cs } from "./marshal-to-cs"; -import { bind_arg_marshal_to_js, end_marshal_task_to_js } from "./marshal-to-js"; +import { bind_arg_marshal_to_js, end_marshal_task_to_js, release_eager_task_holder } from "./marshal-to-js"; import { get_sig, get_signature_argument_count, bound_cs_function_symbol, get_signature_version, alloc_stack_frame, get_signature_type, @@ -203,8 +203,14 @@ function bind_fn_1RA (closure: BindingClosure) { // pre-allocate the promise let promise = res_converter(args); - // call C# side - invoke_async_jsexport(runtimeHelpers.managedThreadTID, method, args, size); + try { + // call C# side + invoke_async_jsexport(runtimeHelpers.managedThreadTID, method, args, size); + } catch (ex) { + // the throw unwinds past end_marshal_task_to_js, which would otherwise adopt it + release_eager_task_holder(promise); + throw ex; + } // in case the C# side returned synchronously promise = end_marshal_task_to_js(args, undefined, promise); @@ -270,8 +276,14 @@ function bind_fn_2RA (closure: BindingClosure) { // pre-allocate the promise let promise = res_converter(args); - // call C# side - invoke_async_jsexport(runtimeHelpers.managedThreadTID, method, args, size); + try { + // call C# side + invoke_async_jsexport(runtimeHelpers.managedThreadTID, method, args, size); + } catch (ex) { + // the throw unwinds past end_marshal_task_to_js, which would otherwise adopt it + release_eager_task_holder(promise); + throw ex; + } // in case the C# side returned synchronously promise = end_marshal_task_to_js(args, undefined, promise); @@ -317,7 +329,13 @@ function bind_fn (closure: BindingClosure) { // call C# side if (is_async) { - invoke_async_jsexport(runtimeHelpers.managedThreadTID, method, args, size); + try { + invoke_async_jsexport(runtimeHelpers.managedThreadTID, method, args, size); + } catch (ex) { + // the throw unwinds past end_marshal_task_to_js, which would otherwise adopt it + release_eager_task_holder(js_result); + throw ex; + } // in case the C# side returned synchronously js_result = end_marshal_task_to_js(args, undefined, js_result); } else if (is_discard_no_wait) { diff --git a/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/invoke-cs.ts b/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/invoke-cs.ts index ca7b7af49027fd..11bc9ddec26d72 100644 --- a/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/invoke-cs.ts +++ b/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/invoke-cs.ts @@ -10,7 +10,7 @@ import { dotnetAssert, dotnetLogger, Module } from "./cross-module"; import { bindAssemblyExports, invokeJSExport } from "./managed-exports"; import { allocStackFrame, getSig, getSignatureType, getSignatureArgumentCount, getSignatureVersion, jsInteropState } from "./marshal"; import { bindArgMarshalToCs } from "./marshal-to-cs"; -import { bindArgMarshalToJs, endMarshalTaskToJs } from "./marshal-to-js"; +import { bindArgMarshalToJs, endMarshalTaskToJs, releaseEagerTaskHolder } from "./marshal-to-js"; import { assertJsInterop, assertRuntimeRunning, endMeasure, isRuntimeRunning, startMeasure } from "./utils"; import { MarshalerType, MeasuredBlock } from "./types"; import { boundCsFunctionSymbol, exportsByAssembly } from "./gc-handles"; @@ -201,8 +201,14 @@ function bindFn_1RA(closure: BindingClosureCS) { // pre-allocate the promise let promise = resConverter(args); - // call C# side - invokeJSExport(methodHandle, args); + try { + // call C# side + invokeJSExport(methodHandle, args); + } catch (ex) { + // the throw unwinds past endMarshalTaskToJs, which would otherwise adopt it + releaseEagerTaskHolder(promise); + throw ex; + } // in case the C# side returned synchronously promise = endMarshalTaskToJs(args, undefined, promise); @@ -266,8 +272,14 @@ function bindFn_2RA(closure: BindingClosureCS) { // pre-allocate the promise let promise = resConverter(args); - // call C# side - invokeJSExport(methodHandle, args); + try { + // call C# side + invokeJSExport(methodHandle, args); + } catch (ex) { + // the throw unwinds past endMarshalTaskToJs, which would otherwise adopt it + releaseEagerTaskHolder(promise); + throw ex; + } // in case the C# side returned synchronously promise = endMarshalTaskToJs(args, undefined, promise); @@ -311,7 +323,13 @@ function bindFn(closure: BindingClosureCS) { // call C# side if (isAsync) { - invokeJSExport(methodHandle, args); + try { + invokeJSExport(methodHandle, args); + } catch (ex) { + // the throw unwinds past endMarshalTaskToJs, which would otherwise adopt it + releaseEagerTaskHolder(jsResult); + throw ex; + } // in case the C# side returned synchronously jsResult = endMarshalTaskToJs(args, undefined, jsResult); } else if (isDiscardNoWait) { From cd9ca9ae613567b0d2ccd724dff0e39ca1752d6d Mon Sep 17 00:00:00 2001 From: pavelsavara Date: Mon, 21 Sep 2026 19:40:54 +0200 Subject: [PATCH 13/14] [browser][coreclr] release the eager Task proxy on the bindAssemblyExports rethrow path abortPosix swallows ExitStatus and RuntimeError and returns, so control reaches the rethrow and the eagerly created proxy stayed rooted. The Mono implementation already released it in the equivalent position. --- .../interop/managed-exports.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/managed-exports.ts b/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/managed-exports.ts index 6c245eca5fc479..f7ce07d981d9ca 100644 --- a/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/managed-exports.ts +++ b/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/managed-exports.ts @@ -167,6 +167,7 @@ export function bindAssemblyExports(assemblyName: string): Promise { if (!error || typeof error.status !== "number") { dotnetBrowserUtilsExports.abortPosix(1, error, true); } + releaseEagerTaskHolder(promise); throw error; } if (isArgsException(args)) { From 015bcf489b4670e731494b766408435a65b60c32 Mon Sep 17 00:00:00 2001 From: pavelsavara Date: Tue, 22 Sep 2026 13:52:24 +0200 Subject: [PATCH 14/14] [browser] rename getProxyCensus to getProxyCounts --- .../InteropServices/JavaScript/JavaScriptTestHelper.cs | 4 ++-- .../Runtime/InteropServices/JavaScript/ProxyLeakTest.cs | 4 ++-- src/mono/browser/runtime/exports-internal.ts | 4 ++-- src/mono/browser/runtime/gc-handles.ts | 4 ++-- .../interop/gc-handles.ts | 4 ++-- .../interop/index.ts | 4 ++-- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System/Runtime/InteropServices/JavaScript/JavaScriptTestHelper.cs b/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System/Runtime/InteropServices/JavaScript/JavaScriptTestHelper.cs index 7e8c1723e2e5c3..a6c00491858baf 100644 --- a/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System/Runtime/InteropServices/JavaScript/JavaScriptTestHelper.cs +++ b/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System/Runtime/InteropServices/JavaScript/JavaScriptTestHelper.cs @@ -1306,8 +1306,8 @@ public static JSObject EchoIJSObject([JSMarshalAs] JSObject arg1) internal static partial void ForceDisposeProxies(bool disposeMethods, bool verbose); // [csOwnedByJsHandle, csOwnedByJsvHandle, jsOwnedRegistered, jsOwnedAlive, importWrappers] - [JSImport("INTERNAL.getProxyCensus")] - internal static partial int[] GetProxyCensus(); + [JSImport("INTERNAL.getProxyCounts")] + internal static partial int[] GetProxyCounts(); [JSImport("forceJsGc", "JavaScriptTestHelper")] internal static partial void ForceJsGc(); diff --git a/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System/Runtime/InteropServices/JavaScript/ProxyLeakTest.cs b/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System/Runtime/InteropServices/JavaScript/ProxyLeakTest.cs index 9f3de0314f580c..9f76b9f623c5a4 100644 --- a/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System/Runtime/InteropServices/JavaScript/ProxyLeakTest.cs +++ b/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System/Runtime/InteropServices/JavaScript/ProxyLeakTest.cs @@ -47,10 +47,10 @@ private static async Task AssertNoLeak(Func run) await run(1); await Quiesce(); - int[] before = JavaScriptTestHelper.GetProxyCensus(); + int[] before = JavaScriptTestHelper.GetProxyCounts(); await run(Iterations); await Quiesce(); - int[] after = JavaScriptTestHelper.GetProxyCensus(); + int[] after = JavaScriptTestHelper.GetProxyCounts(); // Only the JSHandle tables are asserted on. They are maintained by explicit release // calls, which is precisely where a missed release shows up, and they move only in diff --git a/src/mono/browser/runtime/exports-internal.ts b/src/mono/browser/runtime/exports-internal.ts index dded13cf36b13b..5a69b2802a3cc9 100644 --- a/src/mono/browser/runtime/exports-internal.ts +++ b/src/mono/browser/runtime/exports-internal.ts @@ -18,7 +18,7 @@ import { getOptions, applyOptions } from "./jiterpreter-support"; import { mono_wasm_gc_lock, mono_wasm_gc_unlock } from "./gc-lock"; import { loadLazyAssembly } from "./lazyLoading"; import { loadSatelliteAssemblies } from "./satelliteAssemblies"; -import { forceDisposeProxies, get_proxy_census } from "./gc-handles"; +import { forceDisposeProxies, get_proxy_counts } from "./gc-handles"; import { mono_wasm_get_func_id_to_name_mappings } from "./logging"; import { monoStringToStringUnsafe } from "./strings"; import { mono_wasm_bind_cs_function } from "./invoke-cs"; @@ -32,7 +32,7 @@ export function export_internal (): any { Module.err("early exit " + exit_code); }, forceDisposeProxies, - getProxyCensus: get_proxy_census, + getProxyCounts: get_proxy_counts, mono_wasm_dump_threads: WasmEnableThreads ? mono_wasm_dump_threads : undefined, // with mono_wasm_debugger_log and mono_wasm_trace_logger diff --git a/src/mono/browser/runtime/gc-handles.ts b/src/mono/browser/runtime/gc-handles.ts index 86c27f8e542e66..6ddd0a0deadcab 100644 --- a/src/mono/browser/runtime/gc-handles.ts +++ b/src/mono/browser/runtime/gc-handles.ts @@ -187,9 +187,9 @@ function _js_owned_object_finalized (gc_handle: GCHandle): void { teardown_managed_proxy(null, gc_handle); } -// Counts of live proxies, for leak diagnostics and tests. Exposed as INTERNAL.getProxyCensus. +// Counts of live proxies, for leak diagnostics and tests. Exposed as INTERNAL.getProxyCounts. // Order: [csOwnedByJsHandle, csOwnedByJsvHandle, jsOwnedRegistered, jsOwnedAlive, importWrappers] -export function get_proxy_census (): number[] { +export function get_proxy_counts (): number[] { // index 0 of each list is always a dummy const count_live = (list: any[]): number => { let live = 0; diff --git a/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/gc-handles.ts b/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/gc-handles.ts index 1dbdb699335dd5..2693770789297a 100644 --- a/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/gc-handles.ts +++ b/src/native/libs/System.Runtime.InteropServices.JavaScript.Native/interop/gc-handles.ts @@ -196,9 +196,9 @@ function _jsOwnedObjectFinalized(gcHandle: GCHandle): void { teardownManagedProxy(null, gcHandle); } -// Counts of live proxies, for leak diagnostics and tests. Exposed as INTERNAL.getProxyCensus. +// Counts of live proxies, for leak diagnostics and tests. Exposed as INTERNAL.getProxyCounts. // Order: [csOwnedByJsHandle, csOwnedByJsvHandle, jsOwnedRegistered, jsOwnedAlive, importWrappers] -export function getProxyCensus(): number[] { +export function getProxyCounts(): number[] { // index 0 of each list is always a dummy const countLive = (list: any[]): number => { let live = 0; 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 4ae5e50aa54bb4..2963e980fe3c41 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 @@ -14,7 +14,7 @@ import { import { bindCsFunction, getAssemblyExports } from "./invoke-cs"; import { initializeMarshalersToJs, resolveOrRejectPromise } from "./marshal-to-js"; import { initializeMarshalersToCs } from "./marshal-to-cs"; -import { forceDisposeProxies, getProxyCensus, releaseCSOwnedObject } from "./gc-handles"; +import { forceDisposeProxies, getProxyCounts, releaseCSOwnedObject } from "./gc-handles"; import { cancelPromise } from "./cancelable-promise"; import { loadLazyAssembly, loadSatelliteAssemblies } from "./lazy"; import { jsInteropState } from "./marshal"; @@ -52,7 +52,7 @@ export function dotnetInitializeModule(internals: InternalExchange): void { bindCsFunction, loadSatelliteAssemblies, loadLazyAssembly, - getProxyCensus, + getProxyCounts, // WebSocket wsCreate,