Skip to content

Fix #2385: substitute Nullable-wrapped same-compilation user types in imported generic call arguments#2387

Merged
DavidObando merged 3 commits into
mainfrom
fix/2385-nullable-struct-generic-arg
Jul 16, 2026
Merged

Fix #2385: substitute Nullable-wrapped same-compilation user types in imported generic call arguments#2387
DavidObando merged 3 commits into
mainfrom
fix/2385-nullable-struct-generic-arg

Conversation

@DavidObando

Copy link
Copy Markdown
Owner

Summary

Closes #2385 (a deferred follow-up finding from #2381/PR #2384).

List[T?].Add(value) — an imported generic collection (List<T>) closed over a nullable same-compilation struct/enum (e.g. List[Point?]) — produced System.InvalidProgramException at runtime due to invalid IL: a box instruction (or a bare ldnull) was emitted against what is actually a value-type Nullable<T> generic parameter slot.

Root cause

ConversionClassifier.TrySubstituteParameterTypeFromReceiver recovers the substituted parameter type for a call on an imported generic receiver (e.g. List[T].Add(!0)) whose type argument is a same-compilation user type, so the binder binds the correct conversion (T -> Nullable<T>) instead of misclassifying the argument as boxing to the erased CLR shape (object).

Its gate, however, only matched a receiver type argument that was directly a StructSymbol/InterfaceSymbol/EnumSymbol/DelegateTypeSymbol (or a nested ImportedTypeSymbol). A NullableTypeSymbol wrapping one of those — the actual shape of List[Point?]'s type argument — matched none of those cases, so the whole substitution was skipped. The call fell back to the erased CLR parameter type (object), and the argument was bound as a boxing conversion, producing invalid IL against the true Nullable<T> slot.

This is the same class of narrow-gate defect fixed for the analogous issue #2381 (async Task<T> return erasure).

Fix

Widened the gate in TrySubstituteParameterTypeFromReceiver from the ad hoc StructSymbol || InterfaceSymbol || EnumSymbol || DelegateTypeSymbol || nested-ImportedTypeSymbol list to imported.TypeArguments.Any(TypeSymbol.ContainsSameCompilationUserType) — the same general, already-established predicate reused from the #2381 fix. It recurses uniformly through NullableTypeSymbol (and SliceTypeSymbol/ArrayTypeSymbol/TupleTypeSymbol/nested ImportedTypeSymbol), so it is a strict superset of the old list and additionally generalizes, for free, to same-compilation enums wrapped in Nullable<T>.

No emitter changes were needed — MethodBodyEmitter.Conversions.cs already has correct, pre-existing infrastructure (issues #504/#814/#571/#1298/#1572) for both T -> Nullable<T> (newobj Nullable<T>::.ctor) and nil -> Nullable<T> (ldloca; initobj; ldloc) once the binder passes the right target type.

A sibling substitution path used for indexer writes (MapErasedIndexerElementType, issue #968) already handled this case correctly — its gate (arg.ClrType == null) naturally covers a NullableTypeSymbol wrapping a same-compilation struct, since NullableTypeSymbol.ClrType delegates to the underlying type's ClrType (null pre-emit). Only the Add-style call-argument path (TrySubstituteParameterTypeFromReceiver) had the defect; audited via grep to confirm no other site shares the same narrow-list pattern.

Tests

  • Core.Tests (test/Core.Tests/CodeAnalysis/Binding/Issue2385NullableSameCompilationStructGenericArgTests.cs, 6 tests): binder-level assertions on BoundImportedInstanceCallExpression/BoundClrIndexAssignmentExpression argument types for List[T?].Add(concrete)/.Add(nil) with a same-compilation struct and enum, Dictionary[string, T?] indexer-set, plus regression controls for the pre-existing non-nullable same-compilation struct ([ADR-0087 R5] ImportedTypeSymbol ctor rework for closed CLR generic over user type #765) and built-in List[int32?].
  • Compiler.Tests (test/Compiler.Tests/Emit/Issue2385NullableSameCompilationStructGenericArgEmitTests.cs, 5 tests): full compile + IlVerifier.Verify + dotnet exec runtime coverage for the same struct/enum/Dictionary scenarios plus the same two regression controls.

Suite results (this branch, after merging latest origin/main)

  • Core.Tests: 6304 passed, 1 skipped
  • Compiler.Tests: 3103 passed
  • InternalAnalyzers.Tests: 7 passed
  • Cs2Gs.Tests (serialized, xunit.parallelizeAssembly=false xunit.parallelizeTestCollections=false): 1410 passed (2 prior test-host crashes on retry were shared-environment resource contention, not product regressions — a clean 3rd run completed all 1410)
  • Full solution build with RunAnalyzersDuringBuild=true / TreatWarningsAsErrors=true: 0 warnings, 0 errors

Deferred / discovered work

None. Broader coverage (array/tuple-wrapped same-compilation elements as a generic type argument) was reasoned about via ContainsSameCompilationUserType's existing recursive structure but not required to close this issue's reported defect; no new gaps were found while writing the wider test coverage.

DavidObando added 3 commits July 15, 2026 23:47
… imported generic call arguments

TrySubstituteParameterTypeFromReceiver recovers the substituted parameter
type for a call on an imported generic receiver (e.g. List[T].Add(!0))
whose type argument is a same-compilation user type, so the binder inserts
the correct conversion instead of misclassifying the argument as boxing to
the erased CLR shape. Its gate only matched a receiver type argument that
was directly a StructSymbol/InterfaceSymbol/EnumSymbol/DelegateTypeSymbol
(or nested ImportedTypeSymbol) -- a NullableTypeSymbol wrapping one of those
(e.g. List[Point?]) matched none of those cases, so the substitution was
skipped and the call fell back to the erased object parameter type,
emitting an invalid box/ldnull sequence against what is actually a
value-type Nullable<T> generic parameter slot (InvalidProgramException at
runtime).

Widen the gate to TypeSymbol.ContainsSameCompilationUserType -- the same
general predicate already established for the analogous #2381 fix -- which
recurses through NullableTypeSymbol (and Slice/Array/Tuple/nested
ImportedTypeSymbol) uniformly. This is a strict superset of the old list
and additionally generalizes to same-compilation enums wrapped in
Nullable<T>.

Add Core.Tests binder-level coverage (BoundImportedInstanceCallExpression /
BoundClrIndexAssignmentExpression argument-type assertions) and
Compiler.Tests emit-level coverage (compile+run+ILVerify) for:
- List[T?].Add(concrete)/.Add(nil) with a same-compilation struct
- List[T?].Add(concrete)/.Add(nil) with a same-compilation enum
- Dictionary[string, T?] indexer set/get with a same-compilation struct
- Regression controls: List[T] (non-nullable same-compilation struct, #765)
  and List[int32?] (built-in Nullable<int32>) are unaffected.

Fixes #2385.
@DavidObando
DavidObando merged commit 68d6570 into main Jul 16, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

List[T?].Add(...) produces InvalidProgramException when T is a same-compilation struct

1 participant