Skip to content

Dispatch Mono [JSExport] by handle - #134507

Merged
pavelsavara merged 6 commits into
dotnet:mainfrom
pavelsavara:jsexport-dispatch-unification
Sep 29, 2026
Merged

pavelsavara merged 6 commits into
dotnet:mainfrom
pavelsavara:jsexport-dispatch-unification

Conversation

@pavelsavara

@pavelsavara pavelsavara commented Sep 23, 2026 •

Copy link
Copy Markdown
Member

Mono resolved each [JSExport] to a MonoMethod* and kept that pointer in the JS-side binding closure, while CoreCLR already routed calls through a managed entry point. This unifies the two: both flavors now bind an export to an integer handle into a managed table, and the JS side stores a CSFnHandle instead of a MonoMethod.

Beyond the duplication, the Mono lookup could not diagnose a wrapper signature change: mono_class_get_method_from_name is passed -1, which means "any parameter count".

  • JSHostImplementation.Exports.cs (new, shared) holds the reflection-based BindManagedFunction that populates the handle table.
  • JavaScriptExports.Mono.cs gains CallJSExport(int methodHandle, JSMarshalerArgument*), mirroring the CoreCLR entry point.
  • driver.c factors the existing invoke paths into invoke_jsexport_method and adds mono_wasm_set_jsexport_dispatcher plus the *_by_handle sync/async/post entry points.

BindAssemblyExports deliberately stays per-flavor: the CoreCLR version throws when an assembly contains no [JSExport], which the Mono version tolerates, and collapsing that difference would be a behavioral change unrelated to this cleanup.

With dispatch going through managed code on both flavors, the SystemInteropJS_GetAssemblyExport icall and the matching GetAssemblyExport / BindAssemblyExports LibraryImport declarations were dead and are removed.

On AllowUnsafeBlocks

An earlier revision of this PR also dropped Unsafe.SkipInit and the generated unsafe block from the [JSImport] stub, which retired SYSLIB1074 and let user projects build without <AllowUnsafeBlocks>. That has been reverted — see the discussion below with @jkotas.

Removing the pointer syntax only satisfies the compiler's current rule. The stub still calls JSFunctionBinding.InvokeJS and the JSMarshalerArgument marshalers, 44 of whose 104 public ToManaged/ToJS members are declared unsafe. Under the caller-unsafe rules a member-level unsafe modifier requires its callers to be in an unsafe context, so the requirement would return and users who had removed the flag would have to add it back. Removing a requirement and later re-imposing it is worse than never removing it.

LibraryImport requires AllowUnsafeBlocks under both the old and the new rules. JS interop is not different, and making it genuinely caller-safe would need new public API and per-call validation whose cost is not obviously worth it. This PR is therefore only the dispatch unification.

Testing

  • JSImportGenerator.Unit.Tests: 36 run, 0 failed.
  • System.Runtime.InteropServices.JavaScript tests on browser-wasm CoreCLR (V8): 481 run, 472 passed, 0 failed, 9 skipped.
  • Library builds clean for RuntimeFlavor=CoreCLR, RuntimeFlavor=Mono, and Mono with WasmEnableThreads=true.

The Mono runtime path is not exercised by the local runs above and relies on CI for coverage.

Context: #120215

Note

This pull request description was generated with GitHub Copilot.

Two changes that do not need new public API.

CoreCLR already binds exports by storing the generated wrapper in
JSProxyContext.JSExportByHandle and letting JavaScript call CallJSExport with
that handle. Mono instead looked the wrapper up natively by name and invoked
the MonoMethod directly, so the two runtimes disagreed about how a bound
export is reached, and a wrapper signature change would not be diagnosed:
mono_class_get_method_from_name is passed -1 for the signature. Move
BindManagedFunction into a file shared by both flavors, give Mono a
CallJSExport and a handle-dispatching invoke in driver.c, and delete the
SystemInteropJS_GetAssemblyExport internal call. BindAssemblyExports stays
per-flavor: that is assembly bootstrap rather than dispatch, and the two
implementations differ in whether an assembly without exports is an error.

The generated JSImport stub no longer opens an unsafe context or calls
Unsafe.SkipInit, so a project using only [JSImport] no longer needs
AllowUnsafeBlocks and SYSLIB1074 is gone. [JSExport] still generates a
pointer-based wrapper, so SYSLIB1075 remains until that changes.

Also drop Interop.Runtime.BindAssemblyExports and GetAssemblyExport from the
CoreCLR declarations. They were already unreachable: GetAssemblyExport has no
implementation in the CoreCLR native library, and BindAssemblyExports there is
the reverse thunk into managed code, so calling it as a P/Invoke would recurse.
@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 added this to the 12.0.0 milestone Sep 23, 2026
@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 requested a review from maraf September 23, 2026 10:54
@pavelsavara
pavelsavara marked this pull request as ready for review September 23, 2026 18:51
@pavelsavara
pavelsavara requested a review from lewing as a code owner September 23, 2026 18:51
Copilot AI lite review requested due to automatic review settings September 23, 2026 18:51

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Address the thread-context fallback and Mono callback reflection overhead, and remove stale SYSLIB1074 resources.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 2 Medium severity · 1 Low severity

Open (3)
What changed in this PR

This PR removes unsafe code generation from [JSImport] stubs and unifies Mono [JSExport] dispatch through managed handles.

Changes:

  • Replaces Unsafe.SkipInit with safe default initialization.
  • Adds handle-based Mono export dispatch and removes obsolete interop.
  • Updates diagnostics, tests, and generated baselines.

Review findings:

  • Moderate: Avoid requiring the interop thread during export binding; fall back to the main/UI context when needed.
  • Moderate: Avoid reflection and boxing overhead for every Mono export callback.
  • Nit: Remove obsolete SYSLIB1074 descriptors and resource strings.
File Description
src/​mono/​browser/​runtime/​types/​internal.ts Adds CSFnHandle.
src/​mono/​browser/​runtime/​managed-exports.ts Adds handle-based dispatch.
src/​mono/​browser/​runtime/​invoke-cs.ts Stores and invokes export handles.
src/​mono/​browser/​runtime/​driver.c Adds native handle dispatch entry points.
src/​mono/​browser/​runtime/​cwraps.ts Declares new native wrappers.
src/​mono/​browser/​runtime/​corebindings.c Removes obsolete export lookup.
src/​libraries/​System.Runtime.InteropServices.JavaScript/​tests/​JSImportGenerator.UnitTest/​Fails.cs Updates unsafe diagnostic tests.
src/​libraries/​System.Runtime.InteropServices.JavaScript/​tests/​JSImportGenerator.UnitTest/​Compiles.cs Tests imports without unsafe blocks.
src/​libraries/​System.Runtime.InteropServices.JavaScript/​src/​System/​Runtime/​InteropServices/​JavaScript/​JSHostImplementation.Mono.cs Removes Mono-specific binding logic.
src/​libraries/​System.Runtime.InteropServices.JavaScript/​src/​System/​Runtime/​InteropServices/​JavaScript/​JSHostImplementation.Exports.cs Adds shared managed export binding.
src/​libraries/​System.Runtime.InteropServices.JavaScript/​src/​System/​Runtime/​InteropServices/​JavaScript/​JSHostImplementation.CoreCLR.cs Removes duplicated binding logic.
src/​libraries/​System.Runtime.InteropServices.JavaScript/​src/​System/​Runtime/​InteropServices/​JavaScript/​Interop/​JavaScriptExports.Mono.cs Adds the Mono managed dispatcher.
src/​libraries/​System.Runtime.InteropServices.JavaScript/​src/​System.Runtime.InteropServices.JavaScript.csproj Includes the shared exports implementation.
src/​libraries/​System.Runtime.InteropServices.JavaScript/​gen/​JSImportGenerator/​Marshaling/​ImplicitArgumentGenerator.cs Uses default initialization.
src/​libraries/​System.Runtime.InteropServices.JavaScript/​gen/​JSImportGenerator/​Marshaling/​BaseJSGenerator.cs Uses default initialization.
src/​libraries/​System.Runtime.InteropServices.JavaScript/​gen/​JSImportGenerator/​JSSignatureContext.cs Disables skip-init generation.
src/​libraries/​System.Runtime.InteropServices.JavaScript/​gen/​JSImportGenerator/​JSImportGenerator.cs Removes the generated unsafe wrapper.
src/​libraries/​System.Runtime.InteropServices.JavaScript/​gen/​JSImportGenerator/​Analyzers/​JSImportExportDiagnosticsAnalyzer.cs Supports optional unsafe diagnostics.
src/​libraries/​System.Runtime.InteropServices.JavaScript/​gen/​JSImportGenerator/​Analyzers/​JSImportDiagnosticsAnalyzer.cs Stops reporting SYSLIB1074.
src/​libraries/​System.Runtime.InteropServices.JavaScript/​gen/​JSImportGenerator/​Analyzers/​JSExportDiagnosticsAnalyzer.cs Retains SYSLIB1075.
src/​libraries/​Common/​src/​Interop/​Browser/​Interop.Runtime.Mono.cs Removes obsolete interop declarations.
src/​libraries/​Common/​src/​Interop/​Browser/​Interop.Runtime.CoreCLR.cs Removes obsolete interop declarations.

@jkotas

jkotas commented Sep 24, 2026

Copy link
Copy Markdown
Member

no longer emits unsafe code

Is the generated code actually safe according to the new unsafe definition, or are we just missing correct annotations somewhere?

BindManagedFunction used AssertIsInteropThread(), which throws on a thread
without JS interop in the multi-threaded build. JSFunctionBinding.BindManagedFunction
documents the opposite: it can be reached from an assembly module initializer on
such a thread, in which case the export binds to the UI thread. Add
JSProxyContext.BindingContextOrMain() and use it, restoring that fallback.

The JSImport analyzer no longer reports SYSLIB1074, so the descriptor and its
three resource strings were unreferenced. Remove them and leave a marker on the
id so it is not reused. The row in list-of-diagnostics.md stays: ids are retired,
not recycled.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings September 25, 2026 09:15

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

One or more issues must be addressed before approval.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 2 High severity · 1 Medium severity

Open (3)
Resolved since last review (2)

Comment thread src/libraries/Common/src/Interop/Browser/Interop.Runtime.CoreCLR.cs
@pavelsavara

Copy link
Copy Markdown
Member Author

no longer emits unsafe code

Is the generated code actually safe according to the new unsafe definition, or are we just missing correct annotations somewhere?

@jkotas
This PR is cleanup/unification before another PR in which I want to drop all the reflection.
To be able to do that I will propose new public (generator consumed) API to register the wrappers for JSExport.

My goal here was to emit code that doesn't require unsafe keyword for [JSImport].
[JSExport] still requires AllowUnsafeBlocks even after this PR.

To make it safe by the new standard, we also need to

  • validate length of the "stack frame" buffer - easier
  • validate the types of the individual parameters being marshaled - more difficult
    But I have not tried to flip that switch yet.

Both of those are invariants by construction, because the generator is self-consistent. The API would be ideally internal, but we can't do that with generated code. So I'm not 100% clear what are the rules and what are the options. This is interop, doing pointer arithmetics by nature.

I guess we need to solve that before we approve the new APIs.
But I think we don't have to solve it on this PR, this is just a prep/unification

Please advise.

BindingContextOrMain lets BindManagedFunction register an export into a context
owned by another thread, which is the documented module-initializer fallback.
That made the handle allocation and the JSExportByHandle insert race with the
owning thread's CallJSExport lookup: a non-atomic increment and an unsynchronized
Dictionary write against a concurrent read.

Make both fields private and reach them only through AllocJSExportHandle and
TryGetJSExport, which take lock (this) under FEATURE_WASM_MANAGED_THREADS. This
is the pattern the rest of JSProxyContext already uses for its shared tables,
and it compiles away entirely in the single-threaded build.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings September 25, 2026 10:00

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

An unresolved context-selection bug can make registered JSExport handles unresolvable; Mono coverage also depends on CI.

Review effort: Lite
Findings: None

Resolved since last review (3)

@jkotas

jkotas commented Sep 28, 2026

Copy link
Copy Markdown
Member

To make it safe by the new standard
validate length of the "stack frame" buffer
validate the types of the individual parameters being marshaled

Another problem is lifetime. For example, some of the values that are wrapped by JSMarshalerArgument - like GCHandle - have manually managed lifetime. You need to make sure that 100% safe code cannot cause GCHandle double-free or use-after-free when working with JSMarshalerArgument (the usual problem is a copy of the struct created by accident).

I can believe that you may be able to design JavaScript interop source generator that generates safe code only (in the new unsafe model). This hardening would come with performance overhead and it will require new public APIs. I am not sure whether it is worth it. It is common and acceptable for interop to use unsafe code.

the generator is self-consistent

The unsafe code rules apply equally to source code and code generated by the source generator. The AllowUnsafeBlocks requirement cannot be circumvented by claiming that the code was generated by a source generator.

In the new unsafe model - if it is possible to hit a buffer overrun or other type of memory safety violation by calling safe public APIs, it means that annotations on the APIs are not right, and one of more APIs involved in the sequence should be annotated as unsafe.

I think we don't have to solve it on this PR, this is just a prep/unification

Yes, I did not mean to block these cleanups. I commented on this because the PR description seemed to be declaring victory on the use of unsafe code prematurely.

@pavelsavara

Copy link
Copy Markdown
Member Author

In the new unsafe model - if it is possible to hit a buffer overrun or other type of memory safety violation by calling safe public APIs, it means that annotations on the APIs are not right, and one of more APIs involved in the sequence should be annotated as unsafe.

Does generated [LibraryImport] generally force user project to set AllowUnsafeBlocks under the new rules ? Or we don't know yet ?

This hardening would come with performance overhead and it will require new public APIs.
I am not sure whether it is worth it. It is common and acceptable for interop to use unsafe code.

Unless we accept the performance overhead and possibly complexity of the new API which would be safe by new rules, we would have to keep AllowUnsafeBlocks in user project.

That makes "drop AllowUnsafeBlocks" part of my goal on this and on follow-up PRs moot.
I will try again and see how bad it would be to try to comply with the new rules.

@jkotas

jkotas commented Sep 28, 2026

Copy link
Copy Markdown
Member

Does generated [LibraryImport] generally force user project to set AllowUnsafeBlocks under the new rules ? Or we don't know yet ?

Yes. LibraryImport requires AllowUnsafeBlocks both with old and new rules.

Dropping Unsafe.SkipInit and the generated unsafe block removed the pointer
syntax, which is all the compiler's current rule keys on, so SYSLIB1074 stopped
being reported and user projects could build without AllowUnsafeBlocks. That was
masking the problem rather than fixing it.

The generated stub still calls JSFunctionBinding.InvokeJS and the
JSMarshalerArgument marshalers, 44 of whose 104 public ToManaged/ToJS members are
declared unsafe. Under the caller-unsafe rules a member-level unsafe modifier
requires callers to be in an unsafe context, so the requirement would come back
and users who had removed the flag would have to add it again. Removing a
requirement and later re-imposing it is worse than never removing it.

LibraryImport requires AllowUnsafeBlocks under both the old and the new rules;
JS interop is not different. Restore SYSLIB1074, its descriptor and resources,
the SkipInit code emission and the generated unsafe block, leaving this branch
as only the Mono JSExport dispatch unification.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@pavelsavara pavelsavara changed the title Dispatch Mono [JSExport] by handle and drop unsafe from JSImport Dispatch Mono [JSExport] by handle Sep 29, 2026
@pavelsavara

Copy link
Copy Markdown
Member Author

Heads-up on scope: as of a47fdf4 this PR is only the Mono [JSExport] dispatch unification. The AllowUnsafeBlocks / SYSLIB1074 part has been reverted, per the discussion above — Unsafe.SkipInit, the generated unsafe block, the SYSLIB1074 descriptor and its resources are all back to their main state. The title and description are updated to match.

Reasoning: dropping the pointer syntax only satisfies the compiler's current rule. The stub still calls JSFunctionBinding.InvokeJS and the JSMarshalerArgument marshalers, 44 of whose 104 public ToManaged/ToJS members are declared unsafe, so under the new rules the requirement returns. Removing it now and re-imposing it later is a two-way break for users, and LibraryImport keeps the flag under both rule sets.

Two already-resolved review threads now describe code that is no longer here, so please disregard them rather than re-reviewing:

  • the "Remove obsolete JS import diagnostic and SYSLIB1074 resources" thread — that cleanup is undone, the descriptor and resource strings are intentionally back;
  • the "breaks the CoreCLR build" thread — rejected earlier with build evidence, and the file it referenced is untouched by the current diff.

Still current and unchanged: the BindingContextOrMain fix and the handle-table synchronization.

Verified after the revert: JSImportGenerator.Unit.Tests 36 run / 0 failed, and the library builds clean for RuntimeFlavor=CoreCLR, RuntimeFlavor=Mono and Mono with WasmEnableThreads=true.

Note

This comment was generated with GitHub Copilot.

@pavelsavara

Copy link
Copy Markdown
Member Author

/ba-g unrelated CI failures

@pavelsavara
pavelsavara merged commit 7235292 into dotnet:main Sep 29, 2026
96 of 98 checks passed
@pavelsavara
pavelsavara deleted the jsexport-dispatch-unification branch September 29, 2026 15:53
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.

4 participants