[wasm] [JSExport] Task-returning method that completes synchronously permanently leaks Promise/handle
- Dominant language
- C#
- Stars
- 18.3k
- Forks
- 5.6k
- PR merge metrics
- PR metrics pending
Description
### 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
1. 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)
2. `dotnet run`, open the app URL in Chrome/Edge.
3. Open DevTools -> Memory tab, take a heap snapshot ("Snapshot 1").
4. Click "Call Task (sync) x1000" once (calls `CallTaskSync()` 1000 times in a fire-and-forget loop - the return value is never awaited/`.then()`-ed).
5. Force GC, take snapshot 2.
6. Click "Call void (sync) x1000" and "Call Task (real async) x1000" once each (same fire-and-forget pattern, 1000 calls each).
7. Force GC, take snapshot 3.
8. Open Comparison view (snapshot 1 -> snapshot 3), filter constructor name by "Prom".
[JSExportTaskLeakRepro.zip](https://github.com/user-attachments/files/31641225/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` 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.
Contributor guide
Assessment
This issue has not been assessed yet.