[wasm][mono][AOT] Nullable<T> of value types defined in interpreted assemblies still crash when corelib is AOT'd (null function / signature mismatch)
- Dominant language
- C#
- Stars
- 18.3k
- Forks
- 5.6k
- PR merge metrics
- PR metrics pending
Description
## Summary
Follow-up to #131537. The AOT-compiler fix for that issue emits the `Nullable` 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` through the non-generic `IEnumerator.Current` (e.g. `new System.Collections.Queue(new List{...})`) still crashes, with one of two symptoms depending on the struct's gsharedvt layout:
- **Layout matches a covered corelib type** (e.g. a 16-byte `{ long; long }` struct shares the layout of `Int128`): the out-sig wrapper *is* found (it is layout-keyed), but the concrete `Nullable.Box` body is `NULL` → **`MONO_WASM: null function`**.
- **Novel layout**: the wrapper is missing → **`function signature mismatch`**, exactly like #131537.
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:
```xml
<_AOT_InternalForceInterpretAssemblies Include="MyApp.dll" />
```
2. Define a custom value type **in the app assembly** and box a `Nullable` of it through the non-generic enumerator:
```csharp
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 -> object.
var _ = new Queue(new List { new Custom16 { A = 1, B = 2 } });
```
3. 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`.
## Root cause
There are two halves, and only one is layout-shareable:
1. **The out-sig wrapper** `object(Nullable)` is layout-normalized — `mini_get_underlying_signature` → `get_wrapper_shared_type` → `get_wrapper_shared_vtype` maps `Nullable` to `Mono.ValueTuple>`. 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.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.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)` wrapper machinery unnecessary. More invasive, but eliminates the entire class of problem (and the corelib type list added for #131537).
## Related
- #131537 — corelib value types (fixed for `Int128`/`UInt128`/`Half`/… by the AOT-compiler change).
- #131049 — initial partial AOT-compiler fix + test enablement.
- #66220 — the Apple-mobile manifestation of the same `gsharedvt_out_sig` crash.
> [!NOTE]
> This issue was drafted with GitHub Copilot.
Contributor guide
Assessment
This issue has not been assessed yet.