Description
A [JSExport] -annotated method that returns Task and completes synchronously (e.g. return Task.CompletedTask; ) permanently leaks a JS Promise object, its promise-controller ( {isDone, promise, resolve, reject} ), and a JS handle-table slot — on every single call. The leak is unconditional and never resolves, even after the page goes idle and DevTools forces garbage collection.
Root cause ( src/mono/browser/runtime/marshal-to-js.ts , main branch):
• begin_marshal_task_to_js creates a TaskHolder ( holder ), calls mono_wasm_get_js_handle(holder) — which tags holder with cs_owned_js_handle_symbol and registers it under a JS handle — and returns the bare holder.promise (not holder itself) as eagerPromise .
• After the C# method returns, end_marshal_task_to_js 's synchronous-completion branch (for MarshalerType.TaskResolved / TaskRejected ) calls mono_wasm_get_js_handle(eagerPromise) to look up the handle to release. But eagerPromise is the bare Promise , which was never tagged with cs_owned_js_handle_symbol — only holder was. So mono_wasm_get_js_handle ( src/mono/browser/runtime/gc-handles.ts ) finds no existing tag and mints a brand-new, unrelated handle for the Promise object, then immediately releases that handle via SystemInteropJS_ReleaseCSOwnedObject — accomplishing nothing.
• The original handle, under which holder (containing the real Promise + resolve / reject closure) is registered, is never looked up or released. It remains permanently live in _cs_owned_objects_by_js_handle for the life of the page.
Net effect: one orphaned JS handle + Promise + promise-controller + closure leaked per call, forever. This is distinct from the documented "Task/JSObject create a GCHandle and proxy; you can trigger disposal or let GC handle it later" behavior — GC never reclaims these objects because the handle-table entry itself is never released, so nothing is ever eligible for collection.
Reproduction Steps
- Minimal repro project attached (plain
Microsoft.NET.Sdk.WebAssembly, net10.0-browser, no Blazor/third-party dependencies) with 3 [JSExport] methods:
CallTaskSync(): [JSExport] static Task CallTaskSync() => Task.CompletedTask; (buggy path)
CallVoidSync(): [JSExport] static void CallVoidSync() { } (control - no Task)
CallTaskAsyncReal(): [JSExport] static async Task CallTaskAsyncReal() { await Task.Yield(); } (control - genuinely async)
dotnet run, open the app URL in Chrome/Edge.
- Open DevTools -> Memory tab, take a heap snapshot ("Snapshot 1").
- Click "Call Task (sync) x1000" once (calls
CallTaskSync() 1000 times in a fire-and-forget loop - the return value is never awaited/.then()-ed).
- Force GC, take snapshot 2.
- Click "Call void (sync) x1000" and "Call Task (real async) x1000" once each (same fire-and-forget pattern, 1000 calls each).
- Force GC, take snapshot 3.
- Open Comparison view (snapshot 1 -> snapshot 3), filter constructor name by "Prom".
JSExportTaskLeakRepro.zip
Expected behavior
Since CallTaskSync always completes synchronously and hits the TaskResolved fast path, no Promise/handle should be permanently retained — # Deleted should track # New for Promise and {isDone, promise, resolve, reject} after forced GC.
Actual behavior
| Method |
Calls |
Promise objects |
{isDone, promise, resolve, reject} |
Deleted after GC |
CallTaskSync |
1000 |
+1000 |
+1000 |
0 |
CallVoidSync |
1000 |
+0 |
+0 |
n/a (none allocated) |
CallTaskAsyncReal |
1000 |
+0 net |
+0 net |
correctly released on completion |
Regression?
No response
Known Workarounds
- Change the
[JSExport] method's return type from Task/Task<T> to void/the raw T when the method is always synchronous (fire-and-forget, no genuine await). Void/non-Task-returning exports are bound via a different, non-async code path (invoke-cs.ts's bind_fn_*V variants) that never calls begin_marshal_task_to_js/allocates a Promise at all, so the leak is avoided entirely.
- This is not viable if the method must sometimes complete asynchronously (mixed sync/async callers can't know in advance), or if the JS caller genuinely needs to
await/.then() a result.
Configuration
Version used: .NET 10 SDK 10.0.400, net10.0-browser
Browser: confirmed in Microsoft Edge (Chromium-based) DevTools
Other information
#95411 ("[browser] eager allocation of Promise/Task result for async JSImport/JSExport") introduced the begin_marshal_task_to_js/end_marshal_task_to_js split that this bug lives in.
Description
A [JSExport] -annotated method that returns Task and completes synchronously (e.g. return Task.CompletedTask; ) permanently leaks a JS Promise object, its promise-controller ( {isDone, promise, resolve, reject} ), and a JS handle-table slot — on every single call. The leak is unconditional and never resolves, even after the page goes idle and DevTools forces garbage collection.
Root cause ( src/mono/browser/runtime/marshal-to-js.ts , main branch):
• begin_marshal_task_to_js creates a TaskHolder ( holder ), calls mono_wasm_get_js_handle(holder) — which tags holder with cs_owned_js_handle_symbol and registers it under a JS handle — and returns the bare holder.promise (not holder itself) as eagerPromise .
• After the C# method returns, end_marshal_task_to_js 's synchronous-completion branch (for MarshalerType.TaskResolved / TaskRejected ) calls mono_wasm_get_js_handle(eagerPromise) to look up the handle to release. But eagerPromise is the bare Promise , which was never tagged with cs_owned_js_handle_symbol — only holder was. So mono_wasm_get_js_handle ( src/mono/browser/runtime/gc-handles.ts ) finds no existing tag and mints a brand-new, unrelated handle for the Promise object, then immediately releases that handle via SystemInteropJS_ReleaseCSOwnedObject — accomplishing nothing.
• The original handle, under which holder (containing the real Promise + resolve / reject closure) is registered, is never looked up or released. It remains permanently live in _cs_owned_objects_by_js_handle for the life of the page.
Net effect: one orphaned JS handle + Promise + promise-controller + closure leaked per call, forever. This is distinct from the documented "Task/JSObject create a GCHandle and proxy; you can trigger disposal or let GC handle it later" behavior — GC never reclaims these objects because the handle-table entry itself is never released, so nothing is ever eligible for collection.
Reproduction Steps
Microsoft.NET.Sdk.WebAssembly,net10.0-browser, no Blazor/third-party dependencies) with 3[JSExport]methods:CallTaskSync():[JSExport] static Task CallTaskSync() => Task.CompletedTask;(buggy path)CallVoidSync():[JSExport] static void CallVoidSync() { }(control - no Task)CallTaskAsyncReal():[JSExport] static async Task CallTaskAsyncReal() { await Task.Yield(); }(control - genuinely async)dotnet run, open the app URL in Chrome/Edge.CallTaskSync()1000 times in a fire-and-forget loop - the return value is never awaited/.then()-ed).JSExportTaskLeakRepro.zip
Expected behavior
Since CallTaskSync always completes synchronously and hits the TaskResolved fast path, no Promise/handle should be permanently retained — # Deleted should track # New for Promise and {isDone, promise, resolve, reject} after forced GC.
Actual behavior
{isDone, promise, resolve, reject}CallTaskSyncCallVoidSyncCallTaskAsyncRealRegression?
No response
Known Workarounds
[JSExport]method's return type fromTask/Task<T>tovoid/the rawTwhen the method is always synchronous (fire-and-forget, no genuineawait). Void/non-Task-returning exports are bound via a different, non-async code path (invoke-cs.ts'sbind_fn_*Vvariants) that never callsbegin_marshal_task_to_js/allocates a Promise at all, so the leak is avoided entirely.await/.then()a result.Configuration
Version used: .NET 10 SDK 10.0.400,
net10.0-browserBrowser: confirmed in Microsoft Edge (Chromium-based) DevTools
Other information
#95411 ("[browser] eager allocation of Promise/Task result for async JSImport/JSExport") introduced the
begin_marshal_task_to_js/end_marshal_task_to_jssplit that this bug lives in.