dotnet / dotnet/runtime

[wasm] [JSExport] Task-returning method that completes synchronously permanently leaks Promise/handle

Open
#132,966 3 comments 0 reactions 1 assignee Claimed by @pavelsavara View on GitHub
arch-wasm area-System.Runtime.InteropServices.JavaScript os-browser
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

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.