Skip to content

Fix value-returning field-like event null-conditional invocation NRE#2799

Merged
DavidObando merged 2 commits into
mainfrom
davidobando/fix-2798
Jul 24, 2026
Merged

Fix value-returning field-like event null-conditional invocation NRE#2799
DavidObando merged 2 commits into
mainfrom
davidobando/fix-2798

Conversation

@DavidObando

Copy link
Copy Markdown
Owner

Summary

A null-conditional invocation Evt?(args) of a subscriber-less field-like event whose delegate returns a value threw NullReferenceException instead of producing a null/optional result. PR #2797 (issue #2796) fixed the void-returning raise on both the nominal CLR-delegate path and the structural FunctionTypeSymbol path, but both guarded only the void result and emitted an unconditional callvirt Invoke for value-returning delegates — dereferencing a null backing delegate on a fresh event with no subscribers.

Repros (both threw NRE with no subscribers)

package MyLib
import System
class Poll {
    event Ask Func[int32,int32]        // nominal CLR delegate
    func Run() { var r = Ask?(5) }
}
package MyLib
class Poll {
    event Ask (int32) -> int32         // structural function delegate
    func Run() { var r = Ask?(5) }
}

Root cause

  • OverloadResolver.CallBinding.cs: the nominal CLR-delegate null-conditional block guarded only void results, falling through to a plain callvirt Invoke for value-returning delegates.
  • OverloadResolver.Invocations.cs (BuildIndirectDelegateCall): guarded only void FunctionTypeSymbol results.

Fix

Generalize the established nullable-delegate invocation result-slot lowering (the same lowering TryBindNullableDelegateInvocation uses for nullable delegate properties, #2772) to both paths. A value-returning conditional invocation is wrapped in a BoundNullConditionalAccessExpression whose result type is the nullable wrapping of the delegate's return type, allocating a $nres_ result slot for value-typed returns. A null receiver yields null/default, the receiver is evaluated once, and a non-null delegate invokes normally. This is a general binder/emitter fix with no Oahu-specific logic.

Emitted lowering now matches C# Evt?.Invoke(args): value-type returns materialize default(Nullable<T>) on the null branch and wrap T on the not-null branch; reference-type returns leave ldnull.

Tests

New Issue2798NullConditionalValueEventRaiseEmitTests (runtime + ILVerify via the shared IlVerifier.Verify helper):

  • nominal Func<int,int> value-returning: zero-subscriber → null (?? -1 ⇒ -1); subscribed → invokes (10)
  • structural (int32) -> int32 value-returning: zero-subscriber → null fallback; subscribed → invokes
  • nominal Func<string,string> and structural (string) -> string reference-returning: zero-subscriber → null (?? "none"); subscribed → invokes
  • bare result (no ??): r == nil true with no subscribers, false once subscribed
  • unsubscribe-then-call: value-returning raise yields the null fallback again (the SettingsBase.OnChange failure mode, value-returning variant)
  • side-effectful argument: evaluated exactly once on the non-null path and not at all when the receiver is null (short-circuit)

Validation

  • Compiler.Tests: 3504 passed, 0 failed
  • Core.Tests: 6704 passed, 1 skipped, 0 failed
  • All 5 repro shapes (nominal/structural × value/reference, plus bare) compile, run correctly, and pass ILVerify clean
  • Value-type null coalescing (Nullable<int> ?? -1) over the null-conditional result verifies and runs clean, so no separate emitter defect is exposed by the value-type fallback
  • All suites run with MSBUILDDISABLENODEREUSE=1

Fixes #2798

…2798)

A null-conditional invocation `Evt?(args)` of a subscriber-less field-like
event whose delegate returns a value threw NullReferenceException instead of
producing a null/optional result. PR #2797 (issue #2796) fixed the
void-returning raise on both the nominal CLR-delegate path and the structural
FunctionTypeSymbol path, but both guarded only the void result and emitted an
unconditional `callvirt Invoke` for value-returning delegates, dereferencing a
null backing delegate on a fresh event with no subscribers.

Generalize the established nullable-delegate invocation result-slot lowering
(the same lowering TryBindNullableDelegateInvocation uses for nullable delegate
properties, #2772) to both paths:

- OverloadResolver.CallBinding.cs: the nominal CLR `Func<...>` /
  `EventHandler` delegate path now wraps a value-returning `Invoke` in a
  BoundNullConditionalAccessExpression whose result type is the nullable
  wrapping of the delegate's return type, allocating a `$nres_` result slot for
  value-typed returns. A null receiver yields null/default; the receiver is
  evaluated once; a non-null delegate invokes normally.
- OverloadResolver.Invocations.cs (BuildIndirectDelegateCall): the structural
  `(T) -> R` event path applies the same lowering for value-returning fnType.

This is a general emitter/binder fix with no Oahu-specific logic. Covers
nominal Func and structural function delegates, reference- and value-returning
shapes, zero-subscriber / subscribed / unsubscribe-then-call cases,
side-effectful argument single-evaluation and short-circuit, natural `??`
fallback consumption (including value-type null coalescing), runtime behavior,
metadata, and ILVerify. Value-type null coalescing over the null-conditional
result verifies clean, so no separate emitter defect is exposed.

Fixes #2798
…eiver kinds (#2799 follow-up)

BuildIndirectDelegateCall only guarded the null-conditional invocation form
`f?(args)` of a non-nullable structural function-typed receiver when the
variable was the implicit backing field of a field-like event
(ImplicitFieldVariableSymbol). Every other receiver kind reaching that path —
a bare static field (ImplicitStaticFieldVariableSymbol), a bare instance/static
property (ImplicitPropertyVariableSymbol / ImplicitStaticPropertyVariableSymbol),
and a plain local, parameter, or top-level global (LocalVariableSymbol /
ParameterSymbol / GlobalVariableSymbol) — fell through to an unguarded
BoundIndirectCallExpression, silently dropping the `?`: the result was bound as
the non-optional return type (so `r == nil` failed to bind and value-type `??`
misbehaved) and a null receiver NRE-d instead of short-circuiting.

Fix:
- OverloadResolver.Invocations.cs (BuildIndirectDelegateCall): compute the
  receiver load and its static type uniformly for every variable kind, then
  apply the null-conditional guard across all of them. The receiver is
  evaluated exactly once into a capture; value-returning invocations adopt the
  established result-slot lowering (nullable result type, $nres_ slot for
  value-type returns), void invocations are a plain no-op. Extracted the shared
  result construction into BuildNullConditionalDelegateResult, reused by the
  CLR-delegate path too.
- OverloadResolver.CallBinding.cs (nominal CLR-delegate path): fix a distinct,
  tightly-related pre-existing defect — the receiver was computed as a bare
  BoundVariableExpression and never used TryBuildImplicitMemberLoad, so a
  nominal Func<...> value exposed as a bare static field or instance/static
  property ICE-d with GS9998 ("no local slot") on ANY invocation (conditional
  or not). The receiver-load fix mirrors the structural path.

All seven VariableSymbol kinds that can reach these paths are now handled; no
reachable delegate receiver kind silently drops `?` or ICEs. General binder
fix, no Oahu-specific logic.

New Issue2799NullConditionalStructuralDelegateReceiverEmitTests (runtime +
ILVerify): local, parameter, instance property, static field, static property,
and global receivers; value- and void-returning shapes; null short-circuit and
non-null invoke where null is language-reachable (uninitialized function-typed
field CLR default); side-effectful property receiver single-evaluation; bare
`== nil` optional-result semantics; plus nominal CLR Func property/static-member
receivers (conditional and non-conditional).
@DavidObando

Copy link
Copy Markdown
Owner Author

Follow-up (review): generalize null-conditional structural delegate invocation across all receiver kinds

The final review found a tightly-related deferred gap, now completed on this branch.

Gap

BuildIndirectDelegateCall only guarded the null-conditional invocation form f?(args) of a non-nullable structural function-typed receiver when the variable was the implicit backing field of a field-like event (ImplicitFieldVariableSymbol). Every other receiver kind reaching that path fell through to an unguarded BoundIndirectCallExpression, silently dropping the ?.

Proof (before code change)

  • var r = f?(5) where f is a (int32) -> int32 local/parameter: r bound as non-optional int32, so r == nil failed with GS0129 and value-type r ?? -1 hit the value-type ?? emitter limitation — proving the ? was dropped (result should be int32?).
  • A genuinely-null non-nullable local (copy of an uninitialized function-typed field, CLR default null) invoked via the unguarded path would NRE instead of short-circuiting.

Fix

  • OverloadResolver.Invocations.cs (BuildIndirectDelegateCall): compute the receiver load + static type uniformly for every variable kind, then apply the null-conditional guard across all of them — receiver evaluated exactly once into a capture; value-returning invocations adopt the established result-slot lowering (nullable result type, $nres_ slot for value-type returns); void invocations no-op. Shared construction extracted into BuildNullConditionalDelegateResult (reused by the CLR path).
  • OverloadResolver.CallBinding.cs (nominal CLR-delegate path): fixed a distinct pre-existing defect discovered during this work — the receiver was a bare BoundVariableExpression and never used TryBuildImplicitMemberLoad, so a nominal Func<...> value exposed as a bare static field or instance/static property ICE-d with GS9998 ("no local slot") on any invocation, conditional or not. The receiver-load fix mirrors the structural path.

All seven VariableSymbol kinds that can reach these paths are now handled — ImplicitFieldVariableSymbol, ImplicitStaticFieldVariableSymbol, ImplicitPropertyVariableSymbol, ImplicitStaticPropertyVariableSymbol, LocalVariableSymbol, ParameterSymbol, GlobalVariableSymbol — so no reachable delegate receiver kind silently drops ? or ICEs. A non-nullable function parameter cannot be assigned nil by language rules (GS0155/GS0129), so its null branch is not literal-reachable; the ? is still honored (optional result) and non-null invokes. No Oahu-specific logic.

Tests

New Issue2799NullConditionalStructuralDelegateReceiverEmitTests (runtime + ILVerify): local, parameter, instance property, static field, static property, and top-level global receivers; value- and void-returning shapes; null short-circuit + non-null invoke where null is language-reachable; side-effectful property receiver single-evaluation (getter runs exactly once on both branches); bare r == nil optional-result semantics; and the nominal CLR Func property/static-field/static-property receivers (conditional + non-conditional).

Validation

  • Compiler.Tests: 3515 passed, 0 failed (+11 new)
  • Core.Tests: 6704 passed, 1 skipped, 0 failed
  • All shape assemblies pass ILVerify clean; all suites run with MSBUILDDISABLENODEREUSE=1
  • Zero deferred work.

@DavidObando
DavidObando merged commit 5177f1e into main Jul 24, 2026
10 checks passed
@DavidObando
DavidObando deleted the davidobando/fix-2798 branch July 24, 2026 03:50
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.

gsc: value-returning field-like event null invocation NREs with no subscribers

1 participant