Skip to content

Commit e28a7a9

Browse files
fix(emit): open-T nil compare / coalesce / unwrap emit verifiable IL (#839)
Issue #831. The emitter naively produced `ldarg.0; ldnull; ceq` for `self == nil` when `self` had a binder-typed type parameter shape (e.g. `T?` over `[T class]` or unconstrained T). The runtime JIT tolerated it, but `ilverify` rejected the IL with `[StackUnexpected]: found Nullobjref ... expected value 'T'` because `ceq` cannot match an opaque `!!T` stack slot against a managed-null reference at the verifier layer. The same gap existed for `?:` (NullCoalesce) and `!!` (NullAssertion) over open-T operands, which both used `dup; brtrue` patterns the verifier also rejects on opaque type-parameter stack values. Eight `Gsharp.Extensions.Optional` helpers (`Map`, `FlatMap`, `OrElse`, `OrCompute`, `OrThrow`, `IfPresent`, `Filter`, plus a value-type `FlatMap` partner) tripped the gate. Root cause: the existing `liftedBinarySlots` / `NullableValueTypeUnwrapCollector` / `NullableValueTypeCoalesceCollector` paths gated on a static `ClrType`, which open type parameters never have, so every shape over an open T fell through to the bottom `ldnull; ceq` / `dup; brtrue` opcodes. Fix (emit-only — no new BoundNodeKind): * `MethodBodyEmitter.Operators.cs`: * For `T? == nil` / `T? != nil` over an open T (any constraint), emit `box <T?>; ldnull; ceq` and append `ldc.i4.0; ceq` for the `!=` lobe. ECMA-335 III.4.1 specifies that `box` of a `Nullable<T>` yields a managed-null reference when HasValue is false, so the same shape works uniformly for class-, struct-, and unconstrained-T (the JIT elides the box when T resolves to a reference type). * For `T? ?: rhs` over a class/unconstrained-T LHS, spill the LHS to a `T`-typed slot and probe via `box !!T; brfalse fallback`, reloading the slot on the non-null path. * For `!!` over a bare `T` or `T?` operand (class/unconstrained T), spill to a `T`-typed slot and probe via `box !!T; brtrue nonNull`, throwing NullReferenceException on null and reloading the slot on non-null. Handles both the un-narrowed `self T?` shape and the smart-cast narrowed bare-`T` shape produced after an `if self == nil { return }` guard. * `SlotPlanner.cs`: * Extend `NullableValueTypeUnwrapCollector` and `NullableValueTypeCoalesceCollector` to pre-allocate slots for the new open-T paths (typed as `!!T`). Tests (`Issue831OpenTNilCompareEmitTests`, 7 cases): * `OpenTNilCompare_ClassConstrained_RoundTrip_Verifiable` — the original Optional.Map repro. * `OpenTNilCompare_ClassConstrained_NotEquals_Verifiable` — exercises the `!= nil` negation lobe. * `OpenTNullCoalesce_ClassConstrained_Verifiable` — Optional.OrElse repro for `self ?: defaultValue`. * `OpenTNullAssertion_ClassConstrained_NonNull_Verifiable` — smart- cast unwrap after a nil-guard. * `OpenTNullAssertion_ClassConstrained_NullThrows_Verifiable` — confirms NRE still raised on null inputs. * `OpenTNilCompare_StructConstrained_PassesIlverify` — verifies the same box-and-compare guard handles struct-constrained T cleanly. * `OpenTNilCompare_BothOverloads_DispatchedCorrectly` — dual-overload dispatch end-to-end. Each test compiles with gsc, gates the PE through `IlVerifier.Verify`, then runs it via `dotnet exec` and asserts on stdout. `Gsharp.Extensions.Optional` helpers now emit zero ilverify errors (only the pre-existing unrelated `Sequences.Empty()` error remains, tracked separately). Closes #831 Related: #806, ADR-0084, parent #706 Co-authored-by: Copilot CLI <noreply@github.com>
1 parent 02cab8c commit e28a7a9

3 files changed

Lines changed: 615 additions & 22 deletions

File tree

src/Core/CodeAnalysis/Emit/MethodBodyEmitter.Operators.cs

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,49 @@ private void EmitUnary(BoundUnaryExpression u)
7070
return;
7171
}
7272

73+
// Issue #831: `!!` on an open-type-parameter operand whose
74+
// type is bare `T` or `T?` and where T is NOT
75+
// struct-constrained. The bottom `dup; brtrue` path below
76+
// is invalid IL — the verifier rejects `dup` / `brtrue`
77+
// on an opaque `!!T` stack value because it cannot prove T
78+
// is a reference type at the signature layer. Mirror the
79+
// value-type spill: store the operand to a `T`-typed slot,
80+
// probe its non-nullness via `box !!T; brtrue nonNull`,
81+
// throw on the null path, and reload the slot on the
82+
// non-null path. The JIT elides the box when T resolves to
83+
// a reference type at runtime (ECMA-335 III.4.1). This
84+
// catches both the un-narrowed `self!!` over `self T?` and
85+
// the smart-cast `self!!` whose binder-typed operand has
86+
// already collapsed to bare `T` after a null-check guard.
87+
if (TryGetOpenTypeParameter(u.Operand.Type, out var tpOperand))
88+
{
89+
if (!this.receiverSpillSlots.TryGetValue(u, out var tpUnwrapSlot))
90+
{
91+
throw new InvalidOperationException(
92+
"No scratch slot pre-allocated for class-constrained `T?` / open `T` '!!' operand — "
93+
+ "check NullableValueTypeUnwrapCollector and the prepass in CollectLocalsAndLabels.");
94+
}
95+
96+
var tpToken = this.outer.GetElementTypeToken(tpOperand);
97+
var tpNonNull = this.il.DefineLabel();
98+
99+
this.EmitExpression(u.Operand);
100+
this.il.StoreLocal(tpUnwrapSlot);
101+
this.il.LoadLocal(tpUnwrapSlot);
102+
this.il.OpCode(ILOpCode.Box);
103+
this.il.Token(tpToken);
104+
this.il.Branch(ILOpCode.Brtrue, tpNonNull);
105+
106+
var tpNreCtor = this.outer.wellKnown.GetNullReferenceExceptionCtorRef();
107+
this.il.OpCode(ILOpCode.Newobj);
108+
this.il.Token(tpNreCtor);
109+
this.il.OpCode(ILOpCode.Throw);
110+
111+
this.il.MarkLabel(tpNonNull);
112+
this.il.LoadLocal(tpUnwrapSlot);
113+
return;
114+
}
115+
73116
this.EmitExpression(u.Operand);
74117
this.il.OpCode(ILOpCode.Dup);
75118
var nonNull = this.il.DefineLabel();
@@ -188,6 +231,52 @@ private void EmitBinary(BoundBinaryExpression b)
188231
return;
189232
}
190233

234+
// Issue #831: NullCoalesce over a class-constrained (or
235+
// unconstrained) `T?` whose underlying is an open type
236+
// parameter. The underlying T has no static ClrType, so
237+
// neither the value-type Nullable<T> spill branch above nor
238+
// the bottom `dup; brtrue` shape is verifiable IL: the
239+
// verifier rejects `dup` / `brtrue` on an opaque `!!T`
240+
// stack value because it cannot prove T is a reference type
241+
// at the signature layer (ECMA-335 III.1.8). Mirror the
242+
// value-type spill: store the LHS to a `T`-typed slot, probe
243+
// its non-nullness via `box !!T; brtrue/brfalse` (which the
244+
// verifier accepts because `box` always produces an object
245+
// reference), and either reload the slot or evaluate RHS.
246+
// The JIT elides the box at runtime when T resolves to a
247+
// reference type (ECMA-335 III.4.1), so the optimized native
248+
// code is no worse than the original `dup; brtrue` shape.
249+
if (b.Left.Type is NullableTypeSymbol tpNullable
250+
&& tpNullable.UnderlyingType is TypeParameterSymbol tpUnderlying
251+
&& !tpUnderlying.HasValueTypeConstraint)
252+
{
253+
if (!this.nullableCoalesceSpillSlots.TryGetValue(b, out var tpSlot))
254+
{
255+
throw new InvalidOperationException(
256+
"No scratch slot pre-allocated for class-constrained `T?` '?:' LHS — "
257+
+ "check NullableValueTypeCoalesceCollector and the prepass in CollectLocalsAndLabels.");
258+
}
259+
260+
var tpToken = this.outer.GetElementTypeToken(tpUnderlying);
261+
var fallback = this.il.DefineLabel();
262+
var end = this.il.DefineLabel();
263+
264+
this.EmitExpression(b.Left);
265+
this.il.StoreLocal(tpSlot);
266+
this.il.LoadLocal(tpSlot);
267+
this.il.OpCode(ILOpCode.Box);
268+
this.il.Token(tpToken);
269+
this.il.Branch(ILOpCode.Brfalse, fallback);
270+
271+
this.il.LoadLocal(tpSlot);
272+
this.il.Branch(ILOpCode.Br, end);
273+
274+
this.il.MarkLabel(fallback);
275+
this.EmitExpression(b.Right);
276+
this.il.MarkLabel(end);
277+
return;
278+
}
279+
191280
// Defensive: any other value-typed LHS (raw struct or enum)
192281
// remains unsupported by `?:`. Today the encoder rejects
193282
// nullable user-defined structs/enums, so this branch is
@@ -341,6 +430,45 @@ private void EmitBinary(BoundBinaryExpression b)
341430
return;
342431
}
343432

433+
// Issue #831: `T? == nil` / `T? != nil` where the underlying T
434+
// is an open type parameter (class-constrained, struct-constrained
435+
// or unconstrained). The concrete value-type Nullable<T> arm above
436+
// (via liftedBinarySlots + EmitLiftedNullableBinary) only catches
437+
// operands with a static `ClrType`, which open type parameters do
438+
// NOT have — so without this guard the bottom of EmitBinary would
439+
// emit `<T-value>; ldnull; ceq`. ilverify rejects that with
440+
// `[StackUnexpected]: found Nullobjref ... expected value 'T'`
441+
// (class/unconstrained) or `expected value 'Nullable`1<T>'`
442+
// (struct-constrained), because `ceq` cannot match an opaque
443+
// stack slot against a managed-null reference at the verifier
444+
// layer. Box the operand first so the comparison runs against an
445+
// `O` reference. The CLR's `box` opcode has the property that
446+
// `box Nullable<T>` yields the managed-null reference when
447+
// HasValue is false (ECMA-335 III.4.1) — so the same shape
448+
// works uniformly for class-T (boxing a reference is a no-op the
449+
// JIT elides) and struct-T (boxing a Nullable encodes HasValue
450+
// into the reference). We use the LHS expression's full type
451+
// token (the NullableTypeSymbol) so the `box` operand resolves to
452+
// `!!T` for class/unconstrained-T (where T? stores as a bare
453+
// reference slot) and to `Nullable<!!T>` for struct-T (where T?
454+
// stores as a value-typed nullable).
455+
if ((b.Op.Kind == BoundBinaryOperatorKind.Equals || b.Op.Kind == BoundBinaryOperatorKind.NotEquals)
456+
&& TryMatchTypeParameterNilCompare(b, out var tpNilOperand))
457+
{
458+
this.EmitExpression(tpNilOperand);
459+
this.il.OpCode(ILOpCode.Box);
460+
this.il.Token(this.outer.GetElementTypeToken(tpNilOperand.Type));
461+
this.il.OpCode(ILOpCode.Ldnull);
462+
this.il.OpCode(ILOpCode.Ceq);
463+
if (b.Op.Kind == BoundBinaryOperatorKind.NotEquals)
464+
{
465+
this.il.LoadConstantI4(0);
466+
this.il.OpCode(ILOpCode.Ceq);
467+
}
468+
469+
return;
470+
}
471+
344472
// Short-circuit evaluation for logical `&&` and `||`: the right
345473
// operand must not be evaluated when the left operand already
346474
// determines the result. Emit a dup + conditional branch so the
@@ -442,6 +570,88 @@ private void EmitBinary(BoundBinaryExpression b)
442570
EmitNarrowingTruncationIfNeeded(b.Op.Kind, b.Type);
443571
}
444572

573+
/// <summary>
574+
/// Issue #831: matches `T? == nil` / `T? != nil` (and the symmetric
575+
/// `nil == T?` / `nil != T?`) where the underlying T is an open
576+
/// type parameter (class-constrained, struct-constrained, or
577+
/// unconstrained). The concrete value-type Nullable&lt;T&gt; arm
578+
/// (driven by <see cref="liftedBinarySlots"/>) only catches
579+
/// operands with a static <c>ClrType</c>; open type parameters
580+
/// have none, so this match fills the gap. The caller boxes the
581+
/// matched operand using its full nullable type token, which
582+
/// resolves to a bare reference slot for class/unconstrained T and
583+
/// to <c>Nullable&lt;!!T&gt;</c> for struct T.
584+
/// </summary>
585+
private static bool TryMatchTypeParameterNilCompare(
586+
BoundBinaryExpression node,
587+
out BoundExpression operand)
588+
{
589+
if (node.Right.Type == TypeSymbol.Null
590+
&& IsOpenTypeParameterNullable(node.Left.Type))
591+
{
592+
operand = node.Left;
593+
return true;
594+
}
595+
596+
if (node.Left.Type == TypeSymbol.Null
597+
&& IsOpenTypeParameterNullable(node.Right.Type))
598+
{
599+
operand = node.Right;
600+
return true;
601+
}
602+
603+
operand = null;
604+
return false;
605+
}
606+
607+
/// <summary>
608+
/// Issue #831: returns true when <paramref name="t"/> is a
609+
/// <see cref="NullableTypeSymbol"/> wrapping an open type parameter
610+
/// (regardless of constraint). The CLR storage of <c>T?</c> is
611+
/// either a bare <c>!!T</c> reference slot (class/unconstrained T)
612+
/// or a <c>Nullable&lt;!!T&gt;</c> value-typed slot (struct T) —
613+
/// see <see cref="ReflectionMetadataEmitter.GetElementTypeToken"/>.
614+
/// Both shapes need the same `box; ldnull; ceq` lowering for
615+
/// nil-comparison to be verifier-clean: boxing a reference is a
616+
/// JIT-elided no-op, while boxing <c>Nullable&lt;T&gt;</c> per
617+
/// ECMA-335 III.4.1 yields a managed-null reference when
618+
/// HasValue is false.
619+
/// </summary>
620+
private static bool IsOpenTypeParameterNullable(TypeSymbol t)
621+
{
622+
return t is NullableTypeSymbol nullable
623+
&& nullable.UnderlyingType is TypeParameterSymbol;
624+
}
625+
626+
/// <summary>
627+
/// Issue #831: returns true when <paramref name="t"/> resolves to an
628+
/// open type parameter that is NOT struct-constrained, either
629+
/// directly (e.g. after a smart-cast narrowing) or via a
630+
/// <see cref="NullableTypeSymbol"/> wrapper. Used by the `!!`
631+
/// (NullAssertion) emit path to recognise both `self T?` and the
632+
/// narrowed bare-`T` operand shape produced after a preceding
633+
/// nil-check guard.
634+
/// </summary>
635+
private static bool TryGetOpenTypeParameter(TypeSymbol t, out TypeParameterSymbol typeParameter)
636+
{
637+
if (t is TypeParameterSymbol bare && !bare.HasValueTypeConstraint)
638+
{
639+
typeParameter = bare;
640+
return true;
641+
}
642+
643+
if (t is NullableTypeSymbol nullable
644+
&& nullable.UnderlyingType is TypeParameterSymbol wrapped
645+
&& !wrapped.HasValueTypeConstraint)
646+
{
647+
typeParameter = wrapped;
648+
return true;
649+
}
650+
651+
typeParameter = null;
652+
return false;
653+
}
654+
445655
private void EmitNarrowingTruncationIfNeeded(BoundBinaryOperatorKind kind, TypeSymbol resultType)
446656
{
447657
// IL evaluation-stack quirk: arithmetic, bitwise, and shift

src/Core/CodeAnalysis/Emit/SlotPlanner.cs

Lines changed: 70 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -991,13 +991,27 @@ protected override void VisitIndirectAssignmentExpression(BoundIndirectAssignmen
991991
}
992992
}
993993

994-
// Issue #504: walks the bound tree collecting every `BoundUnaryExpression`
995-
// whose operator is `NullAssertion` (`!!`) and whose operand is a
996-
// value-type `Nullable<T>`. Each such site needs a `Nullable<T>`-typed
997-
// temp slot so the emitter can spill the operand and call
998-
// `Nullable<T>::get_Value` (which yields the underlying `T` or throws
999-
// `InvalidOperationException`). The reference-type `!!` path uses the
1000-
// existing `dup; brtrue; throw NRE` pattern and needs no slot.
994+
// Issue #504 + Issue #831: walks the bound tree collecting every
995+
// `BoundUnaryExpression` whose operator is `NullAssertion` (`!!`)
996+
// and whose operand requires an emit-time spill slot. Two shapes
997+
// qualify:
998+
//
999+
// * Value-type `Nullable<T>` operand — the emitter spills the
1000+
// struct to a slot and calls `Nullable<T>::get_Value` (which
1001+
// yields the underlying `T` or throws `InvalidOperationException`).
1002+
//
1003+
// * Open-type-parameter operand whose binder-typed shape is `T?`
1004+
// or bare `T` (after a smart-cast that narrowed `T?` to `T`),
1005+
// where T is NOT struct-constrained. The bare `!!T` stack value
1006+
// cannot be probed via `dup; brtrue` either — the verifier
1007+
// rejects branching on an opaque type-parameter slot. The
1008+
// emitter instead stores the operand to a `T`-typed slot,
1009+
// probes its non-nullness via `box !!T; brtrue nonNull`, throws
1010+
// on null, and reloads the slot on the non-null path.
1011+
//
1012+
// Reference-type `!!` over a concrete reference (string?, Func<T>?,
1013+
// sequence[T], ...) uses the existing `dup; brtrue; throw NRE`
1014+
// pattern and needs no slot.
10011015
private sealed class NullableValueTypeUnwrapCollector : BoundTreeWalker
10021016
{
10031017
private readonly List<BoundUnaryExpression> sink;
@@ -1009,26 +1023,55 @@ public NullableValueTypeUnwrapCollector(List<BoundUnaryExpression> sink)
10091023

10101024
protected override void VisitUnaryExpression(BoundUnaryExpression node)
10111025
{
1012-
if (node.Op.Kind == BoundUnaryOperatorKind.NullAssertion
1013-
&& node.Operand.Type is NullableTypeSymbol n
1014-
&& n.UnderlyingType?.ClrType is { IsValueType: true })
1026+
if (node.Op.Kind == BoundUnaryOperatorKind.NullAssertion)
10151027
{
1016-
this.sink.Add(node);
1028+
bool needsSlot = false;
1029+
1030+
if (node.Operand.Type is NullableTypeSymbol n)
1031+
{
1032+
needsSlot = n.UnderlyingType?.ClrType is { IsValueType: true }
1033+
|| (n.UnderlyingType is TypeParameterSymbol tp && !tp.HasValueTypeConstraint);
1034+
}
1035+
else if (node.Operand.Type is TypeParameterSymbol bare && !bare.HasValueTypeConstraint)
1036+
{
1037+
// Issue #831: smart-cast may narrow `self T?` to bare
1038+
// `T` after a preceding `if self == nil { return }`
1039+
// guard, but the runtime value still needs the
1040+
// verifiable `box; brtrue` shape rather than the
1041+
// direct `dup; brtrue` over an opaque `!!T` slot.
1042+
needsSlot = true;
1043+
}
1044+
1045+
if (needsSlot)
1046+
{
1047+
this.sink.Add(node);
1048+
}
10171049
}
10181050

10191051
base.VisitUnaryExpression(node);
10201052
}
10211053
}
10221054

10231055
// Issue #519: walks the bound tree collecting every `BoundBinaryExpression`
1024-
// whose operator is `NullCoalesce` (`?:`) and whose LHS is a value-type
1025-
// `Nullable<T>`. Each such site needs a `Nullable<T>`-typed temp slot so
1026-
// the emitter can spill the LHS once, call `Nullable<T>::get_HasValue`
1027-
// off the slot's address, and either reload the slot (when the result
1028-
// type is `Nullable<T>`) or call `Nullable<T>::get_Value` off the slot's
1029-
// address (when the result type is the underlying `T`). The reference-
1030-
// type `?:` path uses the existing `dup; brtrue; pop; rhs` pattern and
1031-
// needs no slot.
1056+
// whose operator is `NullCoalesce` (`?:`) and whose LHS needs an emit-time
1057+
// spill slot. Two shapes qualify:
1058+
//
1059+
// * Value-type `Nullable<T>` LHS — the emitter spills the LHS once and
1060+
// calls `Nullable<T>::get_HasValue` / `get_Value` off the slot's
1061+
// address (since `dup; brtrue` is invalid IL for struct stack values).
1062+
//
1063+
// * Issue #831: open-type-parameter `T?` LHS where T is NOT
1064+
// struct-constrained (class-constrained or unconstrained). The bare
1065+
// `!!T` stack value cannot be probed with `dup; brtrue` either —
1066+
// the verifier rejects branching on an opaque type-parameter slot
1067+
// because it cannot prove T is a reference type at the signature
1068+
// layer. The slot is typed as `T?` (which encodes as bare `!!T` per
1069+
// `ReflectionMetadataEmitter.GetElementTypeToken`), and the emitter
1070+
// probes its non-nullness via `box !!T; brfalse fallback`.
1071+
//
1072+
// Reference-type `?:` over a concrete reference type (string?, Func<T>?,
1073+
// sequence[T], etc.) uses the existing `dup; brtrue; pop; rhs` pattern
1074+
// and needs no slot.
10321075
private sealed class NullableValueTypeCoalesceCollector : BoundTreeWalker
10331076
{
10341077
private readonly List<BoundBinaryExpression> sink;
@@ -1041,10 +1084,15 @@ public NullableValueTypeCoalesceCollector(List<BoundBinaryExpression> sink)
10411084
protected override void VisitBinaryExpression(BoundBinaryExpression node)
10421085
{
10431086
if (node.Op.Kind == BoundBinaryOperatorKind.NullCoalesce
1044-
&& node.Left.Type is NullableTypeSymbol n
1045-
&& n.UnderlyingType?.ClrType is { IsValueType: true })
1087+
&& node.Left.Type is NullableTypeSymbol n)
10461088
{
1047-
this.sink.Add(node);
1089+
bool needsSlot = n.UnderlyingType?.ClrType is { IsValueType: true }
1090+
|| (n.UnderlyingType is TypeParameterSymbol tp && !tp.HasValueTypeConstraint);
1091+
1092+
if (needsSlot)
1093+
{
1094+
this.sink.Add(node);
1095+
}
10481096
}
10491097

10501098
base.VisitBinaryExpression(node);

0 commit comments

Comments
 (0)