Skip to content

Fix null-conditional field-like event raise NRE with no subscribers#2797

Merged
DavidObando merged 1 commit into
mainfrom
fix/2796-null-conditional-event-raise
Jul 23, 2026
Merged

Fix null-conditional field-like event raise NRE with no subscribers#2797
DavidObando merged 1 commit into
mainfrom
fix/2796-null-conditional-event-raise

Conversation

@DavidObando

Copy link
Copy Markdown
Owner

Summary

A null-conditional raise Evt?(args) of a field-like event threw NullReferenceException when the event had no subscribers instead of being a no-op. This broke the canonical Evt?(...) / Evt?.Invoke(...) short-circuit for both nominal EventHandler and structural (object?, EventArgs) -> void events, and blocked 3 migrated Oahu tests at Oahu.Core.SettingsBase.OnChange.

Root cause (regression of #2792)

PR #2792 (fix/2726) canonicalizes conventional structural event shapes and nominal EventHandler events to the CLR System.EventHandler / EventHandler<T> delegates. Their invocation therefore now routes through the CLR-delegate call path in OverloadResolver.BindCallExpression (dispatch via Invoke). Unlike the FunctionTypeSymbol and named-delegate paths — which already guard the void raise via BuildIndirectDelegateCall — that path emitted an unconditional callvirt Invoke, dereferencing a null backing delegate on a fresh event.

Commit bisect (from the issue): structural evt_struct_null raise works at 2621fa0a (#2791) and breaks at c84ca771 (#2792); both nominal and structural fail at the cycle-16 tip.

Fix

In the CLR-delegate call path, when a null-conditional invocation targets a void-returning CLR delegate, bind Invoke against a captured receiver and wrap it in a BoundNullConditionalAccessExpression, so a null backing delegate short-circuits to a safe no-op — matching C# Evt?.Invoke(args). This covers both nominal and structural field-like events with no Oahu-specific logic. Value-returning conditional invocations keep their existing lowering.

Emitted IL for the raise now becomes the standard null guard:

ldfld <event>; stloc.0; ldloc.0; brtrue not_null; br end; not_null: ...callvirt Invoke; end:

Tests

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

  • nominal EventHandler zero-subscriber raise no-ops; with a subscriber invokes
  • structural (object?, EventArgs) -> void zero-subscriber raise no-ops; with a subscriber invokes
  • unsubscribe-then-raise no-ops (the SettingsBase.OnChange failure mode)
  • generic EventHandler<T> zero-subscriber no-op and subscribed invoke

Validation

  • Compiler.Tests: 3495 passed, 0 failed
  • Core.Tests: 6704 passed, 1 skipped
  • Minimal repros (evt_raise.gs, evt_struct_null.gs no-op & exit 0; evt_nonnull.gs hits=1) pass and ILVerify clean
  • Migrated Oahu.Core (recompiled with the patched gsc): the 3 previously-failing tests pass; full migrated Oahu.Cli.Tests 342/342 pass (was 339/342)
  • All suites run with MSBUILDDISABLENODEREUSE=1

Fixes #2796

A null-conditional raise `Evt?(args)` of a field-like event threw
NullReferenceException when the event had no subscribers instead of
being a no-op, breaking the canonical `Evt?(...)` / `Evt?.Invoke(...)`
guard for both nominal `EventHandler` and structural
`(object?, EventArgs) -> void` events.

PR #2792 (fix/2726) canonicalizes conventional structural event shapes
and nominal `EventHandler` events to the CLR
`System.EventHandler`/`EventHandler<T>` delegates, so their invocation
now routes through the CLR-delegate call path in
`OverloadResolver.BindCallExpression`. Unlike the FunctionTypeSymbol and
named-delegate paths (which guard the void raise via
`BuildIndirectDelegateCall`), that path emitted an unconditional
`callvirt Invoke`, dereferencing a null backing delegate on a fresh
event and regressing the structural raise that worked before #2792.

Restore the short-circuit: when a null-conditional invocation targets a
void-returning CLR delegate, bind the `Invoke` against a captured
receiver and wrap it in a BoundNullConditionalAccessExpression so a null
backing delegate is a safe no-op, matching C# `Evt?.Invoke(args)`. This
covers both nominal and structural field-like events without any
Oahu-specific logic.

Add runtime + ILVerify coverage for zero-subscriber and subscribed
nominal and structural events, unsubscribe-then-raise, and the generic
`EventHandler<T>` shape.

Fixes #2796
@DavidObando
DavidObando merged commit 27c7071 into main Jul 23, 2026
10 checks passed
@DavidObando
DavidObando deleted the fix/2796-null-conditional-event-raise branch July 23, 2026 22:27
2505817596 pushed a commit to 2505817596/gsharp-net472 that referenced this pull request Jul 24, 2026
…avidObando#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 DavidObando#2797 (issue DavidObando#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, DavidObando#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 DavidObando#2798
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: null-conditional field-like event raise NREs with no subscribers

1 participant