Skip to content

Fix #831: open-T == nil emits verifiable IL - #839

Merged
DavidObando merged 1 commit into
mainfrom
copilot/issue-831
Jun 14, 2026
Merged

Fix #831: open-T == nil emits verifiable IL#839
DavidObando merged 1 commit into
mainfrom
copilot/issue-831

Conversation

@DavidObando

Copy link
Copy Markdown
Owner

Summary

Fixes the emitter to produce verifier-clean IL for nil-related operations on open type-parameter operands (T?, bare T after smart-cast narrowing). Pre-fix the JIT tolerated the IL at runtime but ilverify rejected it — eight Gsharp.Extensions.Optional helpers (the SDK migration introduced by #806) tripped the gate.

Root cause

The existing slot-allocation/emit paths for nullable-related ops (liftedBinarySlots, NullableValueTypeUnwrapCollector, NullableValueTypeCoalesceCollector) gated on the operand's static ClrType. Open type parameters have no ClrType, so every shape over an open T fell through to the bottom of EmitBinary/EmitUnary and produced:

  • ldarg.0; ldnull; ceq for self == nil[StackUnexpected]: found Nullobjref ... expected value 'T'
  • dup; brtrue for self ?: rhsUnexpected type on the stack for dup over an opaque !!T
  • dup; brtrue for self!! — same root cause

Fix (emit-only, no new BoundNodeKind)

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

  • For T? == nil / != nil over any open-T constraint: emit box <T?>; ldnull; ceq (with ldc.i4.0; ceq for the != lobe). Per ECMA-335 III.4.1, box 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 for reference-typed T.
  • For T? ?: rhs over class/unconstrained T: spill the LHS to a T-typed slot, probe via box !!T; brfalse fallback, reload the slot on the non-null path.
  • For !! over class/unconstrained T (both bare T and T? shapes): spill to a T-typed slot, probe via box !!T; brtrue nonNull, throw NRE on null, reload slot on non-null.

src/Core/CodeAnalysis/Emit/SlotPlanner.cs:

  • Extend NullableValueTypeUnwrapCollector and NullableValueTypeCoalesceCollector to pre-allocate !!T-typed slots for the new open-T paths.

Tests

New file test/Compiler.Tests/Emit/Issue831OpenTNilCompareEmitTests.cs (7 cases) — each compiles a sample with gsc, gates the PE through IlVerifier.Verify, and runs it under dotnet exec asserting stdout:

Test Shape covered
OpenTNilCompare_ClassConstrained_RoundTrip_Verifiable Original Optional.Map repro
OpenTNilCompare_ClassConstrained_NotEquals_Verifiable != nil negation lobe
OpenTNullCoalesce_ClassConstrained_Verifiable self ?: defaultValue (OrElse)
OpenTNullAssertion_ClassConstrained_NonNull_Verifiable smart-cast self!! after guard
OpenTNullAssertion_ClassConstrained_NullThrows_Verifiable NRE preserved on null inputs
OpenTNilCompare_StructConstrained_PassesIlverify [T struct] open-T Nullable<T> == nil
OpenTNilCompare_BothOverloads_DispatchedCorrectly dual-overload dispatch end-to-end

Verification

  • dotnet test GSharp.slnall 5,286 tests pass (Core 3262, Compiler 1326, Interpreter 296, LanguageServer 266, Extensions 104, Sdk 32; 1 RegenerateBaseline skipped).
  • ilverify on Gsharp.Extensions.dll — 8 Optional-helper errors → 0. Only the pre-existing unrelated Sequences.Empty() error remains (out of scope for this PR).
  • cd website && npm ci && npm run build — clean build, no broken links.

Links

Closes #831
Related: #806 (SDK migration that surfaced the gap), ADR-0084, parent #706

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
@DavidObando
DavidObando merged commit e28a7a9 into main Jun 14, 2026
7 checks passed
@DavidObando
DavidObando deleted the copilot/issue-831 branch June 14, 2026 18:12
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.

Open-T == nil lowers to verifier-invalid ldnull; ceq against T

2 participants