Skip to content

[wasm][mono][AOT] Nullable<T> of value types defined in interpreted assemblies still crash when corelib is AOT'd (null function / signature mismatch) #131945

Description

@pavelsavara

Summary

Follow-up to #131537. The AOT-compiler fix for that issue emits the Nullable<T> box/unbox gsharedvt out/in-sig wrappers for a fixed set of corelib value types (Int128, UInt128, Half, Decimal, Guid, DateTime, DateTimeOffset, TimeSpan, DateOnly, TimeOnly, IntPtr, UIntPtr). That resolves the reported System.Text.Json cases, but it cannot cover value types defined in a user assembly that runs interpreted while corelib is AOT'd.

Boxing Nullable<CustomStruct> through the non-generic IEnumerator.Current (e.g. new System.Collections.Queue(new List<CustomStruct?>{...})) still crashes, with one of two symptoms depending on the struct's gsharedvt layout:

This is the same underlying limitation as #66220, generalized to user value types.

Repro

WASM, Mono, AOT'd corelib + interpreted app (the WasmTestOnChrome-MONO-ST shape).

  1. In a Blazor/wasm app project, force the app assembly interpreted while corelib stays AOT'd:
<ItemGroup>
  <_AOT_InternalForceInterpretAssemblies Include="MyApp.dll" />
</ItemGroup>
  1. Define a custom value type in the app assembly and box a Nullable<T> of it through the non-generic enumerator:
using System.Collections;
using System.Collections.Generic;

struct Custom16 { public long A; public long B; } // 16-byte, 8-align → same gsharedvt layout as Int128

// Queue(ICollection) enumerates via the non-generic IEnumerator, whose Current
// getter boxes each element Nullable<Custom16> -> object.
var _ = new Queue(new List<Custom16?> { new Custom16 { A = 1, B = 2 } });
  1. Build with /p:RunAOTCompilation=true and run.

Observed:

MONO_WASM: null function
RuntimeError: null function
    at aot_instances_aot_wrapper_gsharedvt_out_sig_obj_..._Mono_dValueTuple_602_3cbyte_2c_20Mono_dValueTuple_602_3clong_2c_20long_3e_3e_
    ...
WASM EXIT 1

A struct with a novel layout (e.g. { int; int; int }, { long; long; long }, or one containing a GC reference) instead reproduces the original function signature mismatch.

Expected: the value round-trips like any corelib Nullable<T>.

Root cause

There are two halves, and only one is layout-shareable:

  1. The out-sig wrapper object(Nullable<T>) is layout-normalized — mini_get_underlying_signatureget_wrapper_shared_typeget_wrapper_shared_vtype maps Nullable<Int128> to Mono.ValueTuple<byte, ValueTuple<long,long>>. So one representative per gsharedvt layout covers all same-layout payloads, which is why a custom 16-byte struct reuses the wrapper emitted for Int128.
  2. The concrete Nullable<T>.Box/Unbox body is per-type and cannot be gsharedvt-shared in the llvmonly minimal gsharedvt. corelib's AOT compiler can only pre-emit it for types it can enumerate — it has no way to know about a user struct. At runtime, class_type_info (for MONO_RGCTX_INFO_NULLABLE_CLASS_BOX/UNBOX) calls mono_jit_compile_method(Box), which in aot-only llvmonly returns NULL (mini-runtime.c, the if (mono_llvm_only) return NULL; branch) and there is no interpreter fallback for this rgctx path. The layout-keyed wrapper is then invoked with a NULL target → "null function".

Because the concrete body cannot be produced by the AOT compiler, this class of failure cannot be fixed purely in the AOT compiler; it needs a runtime-side change.

High-level fix proposal

Two options (not mutually exclusive):

A. Interpreter fallback for the Nullable box/unbox rgctx path (localized).
In class_type_info for MONO_RGCTX_INFO_NULLABLE_CLASS_BOX/UNBOX, when mono_jit_compile_method returns NULL in aot-only llvmonly, materialize an interpreter entry (create_method_pointer_llvmonly) for the concrete Nullable<T>.Box/Unbox and adapt it with the existing (layout-keyed, AOT'd) out/in-sig wrapper. The wrapper stays AOT'd; only the small box/unbox body runs interpreted. Smallest change; removes both the null function and signature mismatch symptoms for any interpreted payload whose layout wrapper exists, and the wrapper set can stay corelib-scoped.

B. Route Nullable box/unbox through a generic by-pointer helper (broader).
Plain (non-Nullable) value-type boxing already works for arbitrary gsharedvt types because it goes through a helper that takes the value by pointer + an rgctx (class) — a fixed signature, no per-type method or wrapper. Making Nullable box/unbox use the same by-pointer shape would remove the concrete-method dependency entirely and make the whole object(Nullable<T>) wrapper machinery unnecessary. More invasive, but eliminates the entire class of problem (and the corelib type list added for #131537).

Related

Note

This issue was drafted with GitHub Copilot.

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions