unlock unlimited code runs for $79, limited time →
NOSSLETTER.techLIVE
back to OSSpedia
dotnet/runtime
senior8/10
refactoringerror-handlingtesting-strategy

[mono][wasm] Box gsharedvt Nullable<T> via a wrapper-free runtime helper

Project: dotnet/runtime | Language: C# | Concepts: refactoring, error-handling, testing-strategy | Difficulty: senior
Link: https://github.com/dotnet/runtime/pull/132153

The problem

On Mono WASM, when corelib is AOT-compiled but a user assembly is interpreted, boxing a Nullable<T> (where T can be any value type) through a non-generic interface (such as IEnumerator.Current in Queue(ICollection)) can fail with a "function signature mismatch" trap. This is because the required per-T wrapper (Nullable<T>.Box) is not emitted in the AOT image, so the runtime cannot box the value.

Why it's non-trivial

A naive fix might try to always emit the needed wrappers, but this is not possible for user-defined types or when the consuming assembly is interpreted. Another workaround (disabling gsharedvt or failing the operation) would cause more methods to be interpreted or not AOT-compiled, regressing performance and coverage. The solution needs to work for all value types, including those defined outside corelib, without requiring per-type codegen or losing AOT coverage.

The approach

The PR introduces a new runtime helper that boxes any Nullable<T> without needing a per-type wrapper. The relevant code changes are:

+MONO_JIT_ICALL (mono_helper_box_nullable) \

Registers a new JIT icall (runtime-callable helper) named mono_helper_box_nullable.

+MonoObject*
+mono_helper_box_nullable (gpointer vbuf, MonoClass *klass)
+{
+	ERROR_DECL (error);
+	MonoObject *result = mono_nullable_box (vbuf, klass, error);
+	mono_error_set_pending_exception (error);
+	return result;
+}

Defines the implementation of the helper: it boxes a nullable value using the existing mono_nullable_box function, handling any errors by setting a pending exception.

+ICALL_EXPORT MonoObject *mono_helper_box_nullable (gpointer vbuf, MonoClass *klass);

Exports the helper so it can be called from JITted code.

+	register_icall (mono_helper_box_nullable, mono_icall_sig_object_ptr_ptr, FALSE);

Registers the helper with the runtime, specifying its signature.

+static MonoInst*
+mini_emit_nullable_box_helper (MonoCompile *cfg, MonoInst *val, MonoClass *klass, int context_used)
+{
+	MonoInst *iargs [2], *addr, *var;
+
+	var = get_vreg_to_inst (cfg, val->dreg);
+	if (!var)
+		var = mono_compile_create_var_for_vreg (cfg, m_class_get_byval_arg (klass), OP_LOCAL, val->dreg);
+	EMIT_NEW_VARLOADA (cfg, addr, var, var->inst_vtype);
+
+	iargs [0] = addr;
+	iargs [1] = mini_emit_get_rgctx_klass (cfg, context_used, klass, MONO_RGCTX_INFO_KLASS);
+	return mono_emit_jit_icall (cfg, mono_helper_box_nullable, iargs);
+}

Adds a new IR helper that emits a call to the new runtime helper, passing the address of the value and the class.

-				if (mini_is_gsharedvt_klass (klass))
-					addr = mini_emit_get_gsharedvt_info_klass (cfg, klass,
-															   MONO_RGCTX_INFO_NULLABLE_CLASS_BOX);
-				else
-					addr = emit_get_rgctx_method (cfg, context_used, method,
-												  MONO_RGCTX_INFO_METHOD_FTNDESC);
+				if (mini_is_gsharedvt_klass (klass))
+					return mini_emit_nullable_box_helper (cfg, val, klass, context_used);
+				addr = emit_get_rgctx_method (cfg, context_used, method,
+											  MONO_RGCTX_INFO_METHOD_FTNDESC);

Replaces the previous per-T wrapper lookup with a call to the new helper when boxing a gsharedvt Nullable<T>.

+		if (cfg->llvm_only) {
+			MonoInst *box_call = mini_emit_nullable_box_helper (cfg, val, klass, context_used);
+			EMIT_NEW_UNALU (cfg, res, OP_MOVE, dreg, box_call->dreg);
+			res->type = STACK_OBJ;
+			res->klass = klass;
+		} else {
 			MonoInst *box_addr = mini_emit_get_gsharedvt_info_klass (cfg, klass,
-													MONO_RGCTX_INFO_NULLABLE_CLASS_BOX);
 			MonoInst *box_call;
 			MonoMethodSignature *box_sig;
 
 			box_sig = mono_metadata_signature_alloc (cfg->mempool, 2);
 			box_sig->ret = m_class_get_this_arg (klass);
 			box_sig->param_count = 1;
 			box_sig->params [0] = m_class_get_byval_arg (klass);
 
-			if (cfg->llvm_only)
-				box_call = mini_emit_llvmonly_calli (cfg, box_sig, &val, box_addr);
-			else
-				box_call = mini_emit_calli (cfg, box_sig, &val, box_addr, NULL, NULL);
+			box_call = mini_emit_calli (cfg, box_sig, &val, box_addr, NULL, NULL);
 			EMIT_NEW_UNALU (cfg, res, OP_MOVE, dreg, box_call->dreg);
 			res->type = STACK_OBJ;
 			res->klass = klass;

Ensures that for the llvm_only (WASM) backend, boxing a nullable uses the new helper, while other backends use the existing logic.

A regression test is added:

+    [Fact]
+    public static void BoxNullableThroughNonGenericEnumerator()
+    {
+        RoundTrip(new List<Int128?> { 1, 2, 3 });
+        RoundTrip(new List<UInt128?> { 1, 2, 3 });
+        RoundTrip(new List<Half?> { (Half)1, (Half)2, (Half)3 });
+        RoundTrip(new List<decimal?> { 1m, 2m, 3m });
+        RoundTrip(new List<Guid?> { Guid.Empty, Guid.NewGuid() });
+        RoundTrip(new List<DateTime?> { DateTime.UnixEpoch, DateTime.MaxValue });
+        RoundTrip(new List<DateTimeOffset?> { DateTimeOffset.UnixEpoch, DateTimeOffset.MaxValue });
+        RoundTrip(new List<TimeSpan?> { TimeSpan.Zero, TimeSpan.FromSeconds(5) });
+    }

This test exercises boxing of various Nullable<T> types through a non-generic interface, reproducing the original failure scenario.

The test project is also configured to force interpretation of the test assembly while corelib is AOT'd, matching the problematic configuration.

From the review thread

Copilot: This test project doesn’t reference CoreCLRTestLibrary like the other regression projects in this directory, which is the established way these tests pick up shared infrastructure (e.g., common test references/attributes). Add a ProjectReference to $(TestLibraryProjectPath) to match nearby tests.

Copilot: All other tests under src/tests/Loader/classloader/generics/regressions use a [Fact] method named TestEntryPoint. Renaming this entrypoint method to TestEntryPoint keeps discovery/execution consistent with the rest of the directory.

Takeaways

  • When a runtime feature depends on per-type codegen that may not be available (e.g., due to AOT/interpretation boundaries), prefer a generic runtime helper over generating wrappers for each type.
  • Adding targeted regression tests that reproduce the exact configuration and failure mode is critical, especially for subtle cross-compilation/runtime issues.
  • Consistent use of shared test infrastructure and naming conventions helps keep large test suites maintainable and discoverable.