From 6f4a6ac539ad0d0ecc5a2ffd2bcda531483da1a6 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Tue, 14 Jul 2026 17:44:24 -0500 Subject: [PATCH 1/6] [wasm] Fix reverse P/Invoke thunk key for nested [UnmanagedCallersOnly] types The CoreCLR wasm reverse-P/Invoke thunk table is keyed by "{Method}#{argCount}:{Assembly}:{Namespace}:{Type}". The generator (PInvokeCollector) used Type.Namespace, which for a nested type returns the enclosing namespace (e.g. "System.Tests" for System.Tests.TimeZoneInfoTests+WindowsUILanguageHelper). The runtime lookup (src/coreclr/vm/wasm/helpers.cpp GetHashCode -> GetFullyQualifiedNameInfo) reports an empty namespace for nested types, so the emitted key never matched at lookup time: LookupThunk returned null, EnsureCodeForUnmanagedCallersOnly left the portable entry point without code, and GetActualCode asserted (precode_portable.cpp:35) on the first cold ldftn of a nested UnmanagedCallersOnly callback. Match the runtime by emitting an empty namespace for nested types. With this, a nested [UnmanagedCallersOnly] callback (e.g. the EnumUILanguages callback used by globalization/culture code) resolves its reverse thunk and the interpreter runs it instead of asserting. Contributes to CoreCLR-WASI library test bring-up (#130129). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ea71f7e7-5477-4e58-bf1a-ca27d4b23d74 --- src/tasks/WasmAppBuilder/coreclr/PInvokeCollector.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/tasks/WasmAppBuilder/coreclr/PInvokeCollector.cs b/src/tasks/WasmAppBuilder/coreclr/PInvokeCollector.cs index 29ccf6c864ec78..fa26f16cad6ef0 100644 --- a/src/tasks/WasmAppBuilder/coreclr/PInvokeCollector.cs +++ b/src/tasks/WasmAppBuilder/coreclr/PInvokeCollector.cs @@ -336,7 +336,10 @@ public PInvokeCallback(MethodInfo method) TypeFullName = t.FullName!; AssemblyName = t.Module!.Assembly!.GetName()!.Name!; AssemblyFQName = t.Module!.Assembly!.GetName()!.FullName!; - Namespace = t.Namespace; + // Nested types: the runtime reverse-thunk key (vm/wasm/helpers.cpp GetHashCode -> + // GetFullyQualifiedNameInfo) reports an empty namespace for nested types, so match that + // here or the emitted g_ReverseThunks key won't be found at lookup time (#130129). + Namespace = t.IsNested ? string.Empty : t.Namespace; MethodName = method.Name!; ReturnType = method.ReturnType!; IsVoid = ReturnType.Name == "Void"; From 5eb01dee8066c0beb3e0df6e7b39eb7e62061e2a Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Tue, 14 Jul 2026 17:51:48 -0500 Subject: [PATCH 2/6] [wasm] Reference tracking issue for nested UCO thunk key limitation Add a reference to https://github.com/dotnet/runtime/issues/130739 documenting that the nested-type reverse-thunk key drops the enclosing-type chain (collisions are caught as a build-time duplicate-key error). The generator is expected to be superseded once R2R generates the reverse thunks directly. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ea71f7e7-5477-4e58-bf1a-ca27d4b23d74 --- src/tasks/WasmAppBuilder/coreclr/PInvokeCollector.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/tasks/WasmAppBuilder/coreclr/PInvokeCollector.cs b/src/tasks/WasmAppBuilder/coreclr/PInvokeCollector.cs index fa26f16cad6ef0..0924ea49109ff8 100644 --- a/src/tasks/WasmAppBuilder/coreclr/PInvokeCollector.cs +++ b/src/tasks/WasmAppBuilder/coreclr/PInvokeCollector.cs @@ -339,6 +339,9 @@ public PInvokeCallback(MethodInfo method) // Nested types: the runtime reverse-thunk key (vm/wasm/helpers.cpp GetHashCode -> // GetFullyQualifiedNameInfo) reports an empty namespace for nested types, so match that // here or the emitted g_ReverseThunks key won't be found at lookup time (#130129). + // This key drops the enclosing-type chain, so nested types with the same simple name in + // different namespaces collide; the duplicate-key check below turns that into a build error. + // Tracked by https://github.com/dotnet/runtime/issues/130739. Namespace = t.IsNested ? string.Empty : t.Namespace; MethodName = method.Name!; ReturnType = method.ReturnType!; From 2e3187db8dc8885217213117df9a51f7e3b44584 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Tue, 14 Jul 2026 19:11:27 -0500 Subject: [PATCH 3/6] [wasm] Add nested-type UnmanagedCallersOnly regression test Address PR review feedback for the nested reverse-P/Invoke thunk key fix: - Add a dedicated positive Wasm.Build.Tests case (UnmanagedCallersOnly_Nested) that cold-ldftns and invokes [UnmanagedCallersOnly] callbacks declared on nested types inside a namespace, asserting they run. This covers the empty-metadata-namespace path the fix enables on both browser and wasi (unlike the incidental, wasi-only TimeZoneInfoTests coverage). - Clarify the PInvokeCollector comment: the duplicate-key check lives in PInvokeTableGenerator.EmitNativeToInterp, not below the constructor line. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5abc3144-1f18-4321-98b4-d7b5c0627e9e --- .../PInvokeTableGeneratorTests.cs | 24 ++++++++++++ .../PInvoke/UnmanagedCallbackNested.cs | 39 +++++++++++++++++++ .../coreclr/PInvokeCollector.cs | 3 +- 3 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 src/mono/wasm/testassets/EntryPoints/PInvoke/UnmanagedCallbackNested.cs diff --git a/src/mono/wasm/Wasm.Build.Tests/PInvokeTableGeneratorTests.cs b/src/mono/wasm/Wasm.Build.Tests/PInvokeTableGeneratorTests.cs index 50b273b53cc963..1fb933ea5457bb 100644 --- a/src/mono/wasm/Wasm.Build.Tests/PInvokeTableGeneratorTests.cs +++ b/src/mono/wasm/Wasm.Build.Tests/PInvokeTableGeneratorTests.cs @@ -177,6 +177,30 @@ public async Task UnmanagedCallersOnly_Namespaced(Configuration config, bool aot } } + [Theory] + [BuildAndRun()] + public async Task UnmanagedCallersOnly_Nested(Configuration config, bool aot) + { + // Regression coverage for the CoreCLR wasm reverse-P/Invoke thunk key of a nested + // [UnmanagedCallersOnly] type. Reflection reports the enclosing namespace for a nested + // type while the runtime reads the (empty) metadata namespace; if PInvokeCollector emits + // the reflection namespace the key never matches and the first cold ldftn asserts. + ProjectInfo info = CopyTestAsset(config, aot, TestAsset.WasmBasicTestApp, "cb_nested"); + string programRelativePath = Path.Combine("Common", "Program.cs"); + ReplaceFile(programRelativePath, Path.Combine(BuildEnvironment.TestAssetsPath, "EntryPoints", "PInvoke", "UnmanagedCallbackNested.cs")); + + string output = PublishForVariadicFunctionTests(info, config, aot); + Assert.DoesNotMatch(".*(warning|error).*>[A-Z0-9]+__Foo", output); + + RunResult result = await RunForPublishWithWebServer(new BrowserRunOptions( + config, + TestScenario: "DotnetRun", + ExpectedExitCode: 42 + )); + Assert.Contains("Namespaced.Outer.Nested.C", result.TestOutput); + Assert.Contains("Namespaced.Outer.Nested.Deeper.D", result.TestOutput); + } + [Theory] [BuildAndRun()] // The test fetches WasmAppBuilder.dll from the Microsoft.NET.Runtime.WebAssembly.Sdk diff --git a/src/mono/wasm/testassets/EntryPoints/PInvoke/UnmanagedCallbackNested.cs b/src/mono/wasm/testassets/EntryPoints/PInvoke/UnmanagedCallbackNested.cs new file mode 100644 index 00000000000000..ef4c1dc1995570 --- /dev/null +++ b/src/mono/wasm/testassets/EntryPoints/PInvoke/UnmanagedCallbackNested.cs @@ -0,0 +1,39 @@ +using System; +using System.Runtime.InteropServices; + +public class Test +{ + public unsafe static int Main() + { + // Cold ldftn of a nested [UnmanagedCallersOnly] callback. The reverse-thunk key + // must use the metadata namespace (empty for nested types) to match the runtime, + // otherwise the lookup misses and the interpreter asserts at method-compile time. + ((delegate* unmanaged)&Namespaced.Outer.Nested.C)(); + ((delegate* unmanaged)&Namespaced.Outer.Nested.Deeper.D)(); + return 42; + } +} + +namespace Namespaced +{ + public class Outer + { + public class Nested + { + [UnmanagedCallersOnly] + public static void C() + { + Console.WriteLine("TestOutput -> Namespaced.Outer.Nested.C"); + } + + public class Deeper + { + [UnmanagedCallersOnly] + public static void D() + { + Console.WriteLine("TestOutput -> Namespaced.Outer.Nested.Deeper.D"); + } + } + } + } +} diff --git a/src/tasks/WasmAppBuilder/coreclr/PInvokeCollector.cs b/src/tasks/WasmAppBuilder/coreclr/PInvokeCollector.cs index 0924ea49109ff8..150a2e50361454 100644 --- a/src/tasks/WasmAppBuilder/coreclr/PInvokeCollector.cs +++ b/src/tasks/WasmAppBuilder/coreclr/PInvokeCollector.cs @@ -340,7 +340,8 @@ public PInvokeCallback(MethodInfo method) // GetFullyQualifiedNameInfo) reports an empty namespace for nested types, so match that // here or the emitted g_ReverseThunks key won't be found at lookup time (#130129). // This key drops the enclosing-type chain, so nested types with the same simple name in - // different namespaces collide; the duplicate-key check below turns that into a build error. + // different namespaces collide; the duplicate-key check in PInvokeTableGenerator + // (EmitNativeToInterp) turns that into a build error. // Tracked by https://github.com/dotnet/runtime/issues/130739. Namespace = t.IsNested ? string.Empty : t.Namespace; MethodName = method.Name!; From 3a36672df933ca0a1b171a8183e406cef655f3ec Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Tue, 14 Jul 2026 20:04:56 -0500 Subject: [PATCH 4/6] [wasm] Add skipped nested UCO name-collision test for #130739 Encode the residual reverse-P/Invoke thunk key limitation as executable documentation: two nested types sharing a simple name under different enclosing types collide because the key drops the enclosing-type chain. The test asserts the desired behavior (both callbacks resolve and run) and is marked [ActiveIssue(#130739)] so it is skipped until the limitation is removed, at which point the attribute can be dropped to lock the fix. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5abc3144-1f18-4321-98b4-d7b5c0627e9e --- .../PInvokeTableGeneratorTests.cs | 25 +++++++++++ .../UnmanagedCallbackNestedConflict.cs | 42 +++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 src/mono/wasm/testassets/EntryPoints/PInvoke/UnmanagedCallbackNestedConflict.cs diff --git a/src/mono/wasm/Wasm.Build.Tests/PInvokeTableGeneratorTests.cs b/src/mono/wasm/Wasm.Build.Tests/PInvokeTableGeneratorTests.cs index 1fb933ea5457bb..ddadf39f056f4b 100644 --- a/src/mono/wasm/Wasm.Build.Tests/PInvokeTableGeneratorTests.cs +++ b/src/mono/wasm/Wasm.Build.Tests/PInvokeTableGeneratorTests.cs @@ -201,6 +201,31 @@ public async Task UnmanagedCallersOnly_Nested(Configuration config, bool aot) Assert.Contains("Namespaced.Outer.Nested.Deeper.D", result.TestOutput); } + [Theory] + [BuildAndRun()] + [ActiveIssue("https://github.com/dotnet/runtime/issues/130739")] + public async Task UnmanagedCallersOnly_NestedConflict(Configuration config, bool aot) + { + // The reverse-P/Invoke thunk key drops the enclosing-type chain, keying only on the + // simple type name plus the (empty) nested namespace. Two nested types that share a + // simple name under different enclosing types therefore collide and currently fail the + // build. This encodes the desired behavior (both callbacks resolve and run) and is + // skipped until #130739 removes the limitation. + ProjectInfo info = CopyTestAsset(config, aot, TestAsset.WasmBasicTestApp, "cb_nested_conflict"); + string programRelativePath = Path.Combine("Common", "Program.cs"); + ReplaceFile(programRelativePath, Path.Combine(BuildEnvironment.TestAssetsPath, "EntryPoints", "PInvoke", "UnmanagedCallbackNestedConflict.cs")); + + PublishForVariadicFunctionTests(info, config, aot); + + RunResult result = await RunForPublishWithWebServer(new BrowserRunOptions( + config, + TestScenario: "DotnetRun", + ExpectedExitCode: 42 + )); + Assert.Contains("Conflicting.OuterA.Conflict.C", result.TestOutput); + Assert.Contains("Conflicting.OuterB.Conflict.C", result.TestOutput); + } + [Theory] [BuildAndRun()] // The test fetches WasmAppBuilder.dll from the Microsoft.NET.Runtime.WebAssembly.Sdk diff --git a/src/mono/wasm/testassets/EntryPoints/PInvoke/UnmanagedCallbackNestedConflict.cs b/src/mono/wasm/testassets/EntryPoints/PInvoke/UnmanagedCallbackNestedConflict.cs new file mode 100644 index 00000000000000..64b469e4c9edf7 --- /dev/null +++ b/src/mono/wasm/testassets/EntryPoints/PInvoke/UnmanagedCallbackNestedConflict.cs @@ -0,0 +1,42 @@ +using System; +using System.Runtime.InteropServices; + +public class Test +{ + public unsafe static int Main() + { + // Two nested types share the simple name "Conflict" under different enclosing types. + // The reverse-thunk key drops the enclosing-type chain (Method#argCount:Assembly::Type), + // so both callbacks currently map to the same key and the build fails (#130739). + ((delegate* unmanaged)&Conflicting.OuterA.Conflict.C)(); + ((delegate* unmanaged)&Conflicting.OuterB.Conflict.C)(); + return 42; + } +} + +namespace Conflicting +{ + public class OuterA + { + public class Conflict + { + [UnmanagedCallersOnly] + public static void C() + { + Console.WriteLine("TestOutput -> Conflicting.OuterA.Conflict.C"); + } + } + } + + public class OuterB + { + public class Conflict + { + [UnmanagedCallersOnly] + public static void C() + { + Console.WriteLine("TestOutput -> Conflicting.OuterB.Conflict.C"); + } + } + } +} From 20175fafe87ee222dcf9bba97bd123290695f55e Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Wed, 15 Jul 2026 11:23:46 -0500 Subject: [PATCH 5/6] [wasm] Fix nested UCO thunk key on the Mono generator too The new UnmanagedCallersOnly_Nested test caught the same bug on Mono: the native-to-interp key mismatched for nested [UnmanagedCallersOnly] types, producing a "null function" trap. The Mono runtime computes the lookup key with mono_class_get_namespace (browser/runtime/runtime.c get_native_to_interp), which is empty for nested types, but the generator emitted the reflection (enclosing) namespace. The token+name fallback in wasm_dl_get_native_to_interp cannot recover this because bsearch compares the key first. Emit the metadata namespace (empty for nested) to match, mirroring the CoreCLR fix. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5abc3144-1f18-4321-98b4-d7b5c0627e9e --- .../wasm/Wasm.Build.Tests/PInvokeTableGeneratorTests.cs | 9 +++++---- src/tasks/WasmAppBuilder/mono/PInvokeCollector.cs | 7 ++++++- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/mono/wasm/Wasm.Build.Tests/PInvokeTableGeneratorTests.cs b/src/mono/wasm/Wasm.Build.Tests/PInvokeTableGeneratorTests.cs index ddadf39f056f4b..fa67ea5441600d 100644 --- a/src/mono/wasm/Wasm.Build.Tests/PInvokeTableGeneratorTests.cs +++ b/src/mono/wasm/Wasm.Build.Tests/PInvokeTableGeneratorTests.cs @@ -181,10 +181,11 @@ public async Task UnmanagedCallersOnly_Namespaced(Configuration config, bool aot [BuildAndRun()] public async Task UnmanagedCallersOnly_Nested(Configuration config, bool aot) { - // Regression coverage for the CoreCLR wasm reverse-P/Invoke thunk key of a nested - // [UnmanagedCallersOnly] type. Reflection reports the enclosing namespace for a nested - // type while the runtime reads the (empty) metadata namespace; if PInvokeCollector emits - // the reflection namespace the key never matches and the first cold ldftn asserts. + // Regression coverage for the wasm reverse-P/Invoke (native-to-interp) thunk key of a + // nested [UnmanagedCallersOnly] type on both runtimes. Reflection reports the enclosing + // namespace for a nested type while the runtime reads the (empty) metadata namespace; if + // PInvokeCollector emits the reflection namespace the key never matches, so the lookup + // returns null - CoreCLR asserts on the first cold ldftn and Mono traps as "null function". ProjectInfo info = CopyTestAsset(config, aot, TestAsset.WasmBasicTestApp, "cb_nested"); string programRelativePath = Path.Combine("Common", "Program.cs"); ReplaceFile(programRelativePath, Path.Combine(BuildEnvironment.TestAssetsPath, "EntryPoints", "PInvoke", "UnmanagedCallbackNested.cs")); diff --git a/src/tasks/WasmAppBuilder/mono/PInvokeCollector.cs b/src/tasks/WasmAppBuilder/mono/PInvokeCollector.cs index 1116f67cb56c66..1c774e60441a43 100644 --- a/src/tasks/WasmAppBuilder/mono/PInvokeCollector.cs +++ b/src/tasks/WasmAppBuilder/mono/PInvokeCollector.cs @@ -226,7 +226,12 @@ public PInvokeCallback(MethodInfo method) Method = method; TypeName = method.DeclaringType!.Name!; AssemblyName = method.DeclaringType!.Module!.Assembly!.GetName()!.Name!; - Namespace = method.DeclaringType!.Namespace; + // Nested types: the runtime native-to-interp key (browser/runtime/runtime.c get_native_to_interp + // via mono_class_get_namespace) reports an empty namespace for nested types, so match that here + // or the emitted wasm_native_to_interp_table key won't be found at lookup time. The token+name + // fallback in wasm_dl_get_native_to_interp cannot recover this because bsearch compares the key + // first, so a key mismatch defeats both lookups. + Namespace = method.DeclaringType!.IsNested ? string.Empty : method.DeclaringType!.Namespace; MethodName = method.Name!; ReturnType = method.ReturnType!; IsVoid = ReturnType.Name == "Void"; From ff725496e9d9b55aeeb3a6ce5e818cbbe904c87d Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Wed, 15 Jul 2026 11:33:29 -0500 Subject: [PATCH 6/6] [wasm] Clarify nested UCO test executes on browser-wasm The Wasm.Build.Tests run path (RunForPublishWithWebServer + BrowserRunOptions) executes on browser-wasm only; the wasi leg is build-only in CI. Note the execution boundary in the test comment so it does not imply wasi run coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5abc3144-1f18-4321-98b4-d7b5c0627e9e --- .../wasm/Wasm.Build.Tests/PInvokeTableGeneratorTests.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/mono/wasm/Wasm.Build.Tests/PInvokeTableGeneratorTests.cs b/src/mono/wasm/Wasm.Build.Tests/PInvokeTableGeneratorTests.cs index fa67ea5441600d..3be221bc216195 100644 --- a/src/mono/wasm/Wasm.Build.Tests/PInvokeTableGeneratorTests.cs +++ b/src/mono/wasm/Wasm.Build.Tests/PInvokeTableGeneratorTests.cs @@ -182,10 +182,11 @@ public async Task UnmanagedCallersOnly_Namespaced(Configuration config, bool aot public async Task UnmanagedCallersOnly_Nested(Configuration config, bool aot) { // Regression coverage for the wasm reverse-P/Invoke (native-to-interp) thunk key of a - // nested [UnmanagedCallersOnly] type on both runtimes. Reflection reports the enclosing - // namespace for a nested type while the runtime reads the (empty) metadata namespace; if - // PInvokeCollector emits the reflection namespace the key never matches, so the lookup - // returns null - CoreCLR asserts on the first cold ldftn and Mono traps as "null function". + // nested [UnmanagedCallersOnly] type. Reflection reports the enclosing namespace for a + // nested type while the runtime reads the (empty) metadata namespace; if PInvokeCollector + // emits the reflection namespace the key never matches, so the lookup returns null - + // CoreCLR asserts on the first cold ldftn and Mono traps as "null function". Executed on + // browser-wasm in CI for both the Mono and CoreCLR generators (the wasi leg is build-only). ProjectInfo info = CopyTestAsset(config, aot, TestAsset.WasmBasicTestApp, "cb_nested"); string programRelativePath = Path.Combine("Common", "Program.cs"); ReplaceFile(programRelativePath, Path.Combine(BuildEnvironment.TestAssetsPath, "EntryPoints", "PInvoke", "UnmanagedCallbackNested.cs"));