Skip to content

[wasm][coreclr] Link user-generated reverse pinvoke table into dotnet.native.wasm #128181

@maraf

Description

@maraf

Note

This issue was drafted with the help of GitHub Copilot.

Summary

On CoreCLR-Wasm, user [UnmanagedCallersOnly] methods invoked via function pointer ((delegate* unmanaged<...>)&Method) or via a native callback (e.g. C code calling back into a UCO export) cannot be resolved at runtime. The user-generated reverse pinvoke table (reverse-pinvoke-table.cpp) is not linked into dotnet.native.wasm, so the runtime's reverse-thunk lookup fails and PortableEntryPoint::GetActualCode asserts HasNativeCode(), or wasm-ld fails with undefined symbol: <ManagedFunc> when a user .c file imports the export.

Two Wasm.Build.Tests are currently skipped on CoreCLR for this reason ([TestCategory("mono")] added in b4a93e8):

  • PInvokeTableGeneratorTests.UnmanagedCallersOnly_Namespaced
  • PInvokeTableGeneratorTests.UCOWithSpecialCharacters

Current state on CoreCLR-Wasm

Runtime side

src/coreclr/vm/wasm/callhelpers-reverse.cpp is a ~1300-line generated source containing the framework's reverse-thunk table:

const ReverseThunkMapEntry g_ReverseThunks[] = { ... };          // framework UCOs
const size_t g_ReverseThunksCount = sizeof(g_ReverseThunks)/sizeof(g_ReverseThunks[0]);

It is compiled once when CoreCLR is built and shipped inside libcoreclr_static.a (the runtime pack at runtimes/browser-wasm/native/libcoreclr_static.a). It also defines SystemInteropJS_* exports, so the archive object is always pulled in by the linker.

At startup, helpers.cpp:CreateReverseThunkHashTable seeds a hash table from g_ReverseThunks[]:

for (size_t i = 0; i < g_ReverseThunksCount; i++)
    newTable->Add(&g_ReverseThunks[i]);

LookupThunk(MethodDesc*) hashes by (typeFullName + methodName) and returns (MethodDesc**, void* entryPoint).

Generator side

src/tasks/WasmAppBuilder/coreclr/PInvokeTableGenerator.cs emits reverse-pinvoke-table.cpp containing:

  • Call_<sym> interpreter dispatch stubs (one per user UCO method).
  • extern "C" <retType> <EntryPoint>(...) C-callable wrappers (when [UnmanagedCallersOnly(EntryPoint = "...")]).
  • An app-level table using the same names:
    const ReverseThunkMapEntry g_ReverseThunks[] = { ... };
    const size_t g_ReverseThunksCount = ...;

Why the user file isn't linked today

src/mono/browser/build/BrowserWasmApp.CoreCLR.targets explicitly excludes reverse-pinvoke-table.cpp from compilation:

<!-- Note: reverse-pinvoke-table.cpp is NOT compiled separately because
     libcoreclr_static.a already contains callhelpers-reverse.cpp.o with the
     default reverse-thunk table. Compiling the app-generated version would
     cause duplicate symbol errors when the archive object is pulled in for
     other required symbols (e.g., SystemInteropJS_*). -->

So:

  1. The user assembly's UCO methods are scanned and a reverse-pinvoke-table.cpp is generated.
  2. The file is never compiled → Call_<sym> stubs and extern "C" exports are never produced.
  3. For function-pointer invocation: managed code does (delegate* unmanaged<void>)&Method)() → interpreter InvokeCalliStubPortableEntryPoint::GetActualCode(ftn) asserts HasNativeCode().
  4. For native callback into UCO: user's local.c imports ManagedFuncwasm-ld reports undefined symbol: ManagedFunc.

Additionally, the auto-enable logic in _CoreCLRSetWasmBuildNativeDefaults only sets WasmBuildNative=true when the project has NativeFileReference or NativeLibrary items. A project that uses UCO only via function pointers (no native files) never relinks at all.

How Mono solves this

Mono ships the reverse-thunk dispatch code as source, not as a precompiled object, and recompiles it during the app relink:

Runtime source ships in the pack

src/mono/browser/runtime/pinvoke.c is a small .c file that conditionally #includes either the user's generated header or an empty default:

#ifdef GEN_PINVOKE
#include "pinvoke-table.h"             // user-generated, written by ManagedToNativeGenerator
#else
#include "pinvoke-tables-default.h"    // empty stubs, used when shipping the prebuilt wasm
#endif

pinvoke.c is listed in _WasmRuntimePackSrcFile in src/mono/browser/build/BrowserWasmApp.targets. During an app relink, the build:

  1. Generates pinvoke-table.h with wasm_native_to_interp_table[], wasm_native_to_interp_ftndescs[], and the per-export functions (decorated with __attribute__((export_name("...")))).
  2. Compiles a fresh pinvoke.c.o with -DGEN_PINVOKE=1 (see BrowserWasmApp.targets:212).
  3. Links it into the new dotnet.native.wasm.

There is no precompiled pinvoke.c.o inside any static archive — so there is no duplicate-symbol conflict.

Lookup is by token + key, not by hash

wasm_dl_get_native_to_interp(token, key, extra_arg) bsearches the user's wasm_native_to_interp_table[], populates the global wasm_native_to_interp_ftndescs[] slot for that callback, and returns the per-callback wrapper. The wrapper internally calls mono_wasm_marshal_get_managed_wrapper on first use to resolve the interp entry function.

Generator side

src/tasks/WasmAppBuilder/mono/PInvokeTableGenerator.cs::EmitNativeToInterp writes the wasm_native_to_interp_table[] entries plus per-callback C functions. Exports are emitted as:

__attribute__((export_name("EntryPoint")))
int wasm_native_to_interp_<asm>_<ns>_<type>_<method>(int arg0) { ... }

No separate extern "C" wrapper is needed (Mono emits a single function that is both the table entry and the wasm export).

Proposal: mirror the Mono approach on CoreCLR

Apply the same "ship source, recompile on relink" pattern:

  1. Split callhelpers-reverse.cpp into:

    • callhelpers-reverse-builtin.cpp — keeps the framework g_ReverseThunks table and SystemInteropJS_* exports. Stays in libcoreclr_static.a.
    • A small new source file shipped in the runtime pack (alongside framework .a files) that conditionally includes the app-generated header:
      #ifdef GEN_PINVOKE
      #include "app-reverse-pinvoke-table.h"
      #else
      const ReverseThunkMapEntry g_AppReverseThunks[1] = { { 0, "", { nullptr, nullptr } } };
      const size_t g_AppReverseThunksCount = 0;
      #endif
  2. Update PInvokeTableGenerator.cs to:

    • Rename the user table to g_AppReverseThunks / g_AppReverseThunksCount (avoids collisions with the framework table).
    • Emit a header with the table + a .cpp containing the Call_<sym> dispatch stubs and extern "C" wrappers. The extern "C" wrapper bug for non-void return types is already fixed in 70bf73d.
  3. Update helpers.cpp:CreateReverseThunkHashTable to seed from both tables:

    for (size_t i = 0; i < g_ReverseThunksCount; i++)    newTable->Add(&g_ReverseThunks[i]);
    for (size_t i = 0; i < g_AppReverseThunksCount; i++) newTable->Add(&g_AppReverseThunks[i]);
  4. Update BrowserWasmApp.CoreCLR.targets to:

    • Drop the exclusion comment for reverse-pinvoke-table.cpp.
    • Add the new runtime source file to the relink compile list and pass -DGEN_PINVOKE=1.
    • Add reverse-pinvoke-table.cpp (containing Call_<sym> + extern "C" wrappers) to _WasmSourceFileToCompileGenerated.
    • Extend _CoreCLRSetWasmBuildNativeDefaults to also enable WasmBuildNative=true for OutputType=Exe browser-wasm CoreCLR projects (so apps with UCOs but no NativeFileReference still relink). Keep OutputType=Library untouched so project-reference libraries (e.g. LazyLibrary.csproj in WBT) are not affected.
  5. Re-enable the skipped tests in PInvokeTableGeneratorTests (remove [TestCategory("mono")] on UnmanagedCallersOnly_Namespaced and UCOWithSpecialCharacters).

Alternative considered

Weak-symbol defaults in callhelpers-reverse.cpp for a renamed g_AppReverseThunks. Smaller change but less idiomatic in this repo and diverges from the Mono pattern that contributors already know.

Related

/cc @maraf

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions