Skip to content

[browser] release eagerly created Task/Promise proxies on non-normal async interop paths - #134124

Draft
pavelsavara wants to merge 10 commits into
dotnet:mainfrom
pavelsavara:js_interop_leaks
Draft

pavelsavara wants to merge 10 commits into
dotnet:mainfrom
pavelsavara:js_interop_leaks

Conversation

@pavelsavara

Copy link
Copy Markdown
Member

Summary

Every async marshaling path eagerly allocates one half of a Task/Promise pair before it knows whether the other half will ever arrive. The normal paths release it; three non-normal ones did not, leaking a proxy per call for the life of the page.

1. A [JSExport] returning a Task that completes synchronously

begin_marshal_task_to_js registers the TaskHolder under a JSHandle but hands back the bare holder.promise. The synchronous-completion branch of end_marshal_task_to_js then looked the handle up from the promise, which was never tagged, so it minted a fresh unrelated handle, released that, and left the holder's real handle live forever.

The promise now carries the holder's handle number, so the holder can be released without being retained by the promise. Fixed in both the CoreCLR and Mono interop trees.

2. getAssemblyExports failing before the promise is handed over

Same eager holder, released on the success path only; an early error branch dropped it. (CoreCLR tree only — the Mono wrapper has no equivalent branch.)

3. An async [JSImport] that fails before JS adopts the holder

InvokeJSImportImpl pre-creates a PromiseHolder and its GCHandle before calling into JS. If JS threw, or returned without producing a promise, nothing freed the holder. It 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 exists, since a pre-created holder has none until JS adopts it.

Tests

ProxyLeakTest covers all four Task/Promise crossings — managed Task in and out as both return value and argument — for completed and pending results, and for results that JS/managed either observes or abandons. It runs each case once to warm the bindings, then 100 times, and asserts the JSHandle tables did not grow.

Sensitivity was checked by reverting each fix: the affected counts move from 1 to 101, so the tests fail without the product changes.

Only the JSHandle tables are asserted on. They are maintained by explicit release calls, which is exactly where a missed release shows up. The GCHandle table behind them is drained by the JS FinalizationRegistry a few entries per turn, so it lags by an unbounded amount — raising the quiesce loop from 3 rounds to 20 moved a count from 101 down to 29 without ever reaching a steady state, which is why asserting on it would be fragile rather than stricter.

The class is restricted to Chromium (draining a proxy needs globalThis.gc, exposed by the --expose-gc argument this project passes for Chrome) and to a single-threaded runtime (with managed threads the census also counts proxies owned by other threads, which drain independently of the test).

Validation

  • Mono multithreaded, Chrome: 274 run, 257 passed, 0 failed, 17 skipped.
  • The single-threaded CoreCLR suite was green (507 run, 505 passed, 0 failed, 2 skipped) at an earlier point on this branch, but not on the tree as it now stands — the workspace lost its CoreCLR build. Re-running it is the remaining work before this leaves draft.

Notes

A fourth leak found in the same sweep — a cancelled HTTP response read orphaning its fetch promise — is split out into #134069, since it is a System.Net.Http concern rather than a marshaling one.

Resolves #132966

Note

This pull request description was generated with the assistance of GitHub Copilot.

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 dotnet#132966
…ask 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 dotnet#132966
…re 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.
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.
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.
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.
@pavelsavara pavelsavara self-assigned this Sep 17, 2026
@pavelsavara pavelsavara added arch-wasm WebAssembly architecture os-browser Browser variant of arch-wasm labels Sep 17, 2026
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 3 pipeline(s).
13 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@pavelsavara
pavelsavara deployed to copilot-pat-pool September 17, 2026 10:29 — with GitHub Actions Active
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to 'arch-wasm': @lewing, @pavelsavara
See info in area-owners.md if you want to be subscribed.

@pavelsavara
pavelsavara deployed to copilot-pat-pool September 17, 2026 10:30 — with GitHub Actions Active
…xport 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

arch-wasm WebAssembly architecture area-System.Runtime.InteropServices.JavaScript os-browser Browser variant of arch-wasm

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant