Fix #2385: substitute Nullable-wrapped same-compilation user types in imported generic call arguments#2387
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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?]) — producedSystem.InvalidProgramExceptionat runtime due to invalid IL: aboxinstruction (or a bareldnull) was emitted against what is actually a value-typeNullable<T>generic parameter slot.Root cause
ConversionClassifier.TrySubstituteParameterTypeFromReceiverrecovers 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 nestedImportedTypeSymbol). ANullableTypeSymbolwrapping one of those — the actual shape ofList[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 trueNullable<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
TrySubstituteParameterTypeFromReceiverfrom the ad hocStructSymbol || InterfaceSymbol || EnumSymbol || DelegateTypeSymbol || nested-ImportedTypeSymbollist toimported.TypeArguments.Any(TypeSymbol.ContainsSameCompilationUserType)— the same general, already-established predicate reused from the #2381 fix. It recurses uniformly throughNullableTypeSymbol(andSliceTypeSymbol/ArrayTypeSymbol/TupleTypeSymbol/nestedImportedTypeSymbol), so it is a strict superset of the old list and additionally generalizes, for free, to same-compilation enums wrapped inNullable<T>.No emitter changes were needed —
MethodBodyEmitter.Conversions.csalready has correct, pre-existing infrastructure (issues #504/#814/#571/#1298/#1572) for bothT -> Nullable<T>(newobj Nullable<T>::.ctor) andnil -> 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 aNullableTypeSymbolwrapping a same-compilation struct, sinceNullableTypeSymbol.ClrTypedelegates to the underlying type'sClrType(nullpre-emit). Only theAdd-style call-argument path (TrySubstituteParameterTypeFromReceiver) had the defect; audited viagrepto confirm no other site shares the same narrow-list pattern.Tests
test/Core.Tests/CodeAnalysis/Binding/Issue2385NullableSameCompilationStructGenericArgTests.cs, 6 tests): binder-level assertions onBoundImportedInstanceCallExpression/BoundClrIndexAssignmentExpressionargument types forList[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-inList[int32?].test/Compiler.Tests/Emit/Issue2385NullableSameCompilationStructGenericArgEmitTests.cs, 5 tests): full compile +IlVerifier.Verify+dotnet execruntime coverage for the same struct/enum/Dictionary scenarios plus the same two regression controls.Suite results (this branch, after merging latest
origin/main)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)RunAnalyzersDuringBuild=true/TreatWarningsAsErrors=true: 0 warnings, 0 errorsDeferred / 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.