Skip to content

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

Description

@appel1

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

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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions