Files
sx/issues/0180-null-coalesce-generic-and-nontrivial-optional-defects.md
agra 58f97fff10 fix: diagnose ?? with a non-optional lhs instead of codegen panic (issue 0172)
lowerNullCoalesce fed resolveOptionalInner's .unresolved (returned for a
non-optional lhs) into the merge-block params / optionalUnwrap / RHS
target type, reaching codegen and panicking 'unresolved type reached
LLVM emission'. Guard: when inferExprType(nc.lhs) is a resolved
non-optional type, emit a located diagnostic and bail; an .unresolved
lhs (prior error) is excluded to avoid double-report. ?? is optional-only
per specs.md (error unions use or/catch), so rejecting a failable lhs is
correct; comptime panic closed too.

Regression: examples/diagnostics/1200-diagnostics-null-coalesce-non-optional.sx.
Verified by 3 adversarial reviews, suite 790/0. Filed adjacent bug 0180
(?? lowering defects for generic/alias/tuple optional lhs).
2026-06-23 03:31:58 +03:00

2.5 KiB

0180 — ?? lowering defects for generic / alias / tuple optional lhs (wrong fallback, segfault, PHI mismatch)

Symptom

lowerNullCoalesce mishandles several non-trivial optional lhs shapes (all with a genuinely-optional lhs, so the issue-0172 non-optional guard correctly does not fire). Found during adversarial review of issue 0172.

  1. Generic ?? returns the WRONG fallback (VERIFIED — silent miscompile): a ?? inside a generic function where the lhs is a type-param-typed optional ?T drops the RHS default and returns the zero payload instead.
  2. Alias-optional with a struct-literal default segfaults (reported by review): ?Opt ?? Opt.{} where Opt :: ?Struct crashes in type interning (hashString/wyhash).
  3. ?(i32) ?? i32 LLVM PHI-type mismatch (reported by review): an optional-of-1-tuple coalesced with a scalar default emits phi { i32 } vs i32.

(Scalar-default alias-optional Opt :: ?i64; o ?? 7 works correctly — the defects are specific to the shapes above.)

Reproduction

(1) Generic ?? wrong fallback — VERIFIED, prints 0, expected 99:

#import "modules/std.sx";
unwrap_or :: (d: $T, x: ?T) -> T { return x ?? d; }
main :: () { b : ?i32 = null; print("{}\n", unwrap_or(99, b)); }  // prints 0 — WRONG

(2) Alias-optional + struct-literal default (reported segfault):

#import "modules/std.sx";
S :: struct { a: i64 = 0; }
Opt :: ?S;
main :: () { o : Opt = null; x := o ?? S.{ a = 7 }; print("{}\n", x.a); }

(3) Optional-of-tuple + scalar default (reported PHI mismatch):

#import "modules/std.sx";
main :: () { o : ?(i32) = null; x := o ?? 5; }

Investigation prompt

src/ir/lower/expr.zig lowerNullCoalesce. For (1), when the lhs optional's child is a TYPE PARAMETER (?T, resolved per monomorphization), the present/ absent merge appears to drop the RHS default and yield the zero payload — check that inner_ty and the merge-block param resolve to the monomorphized child and that the RHS (d) is correctly selected on the null path. For (2)/(3), the merge of present-payload vs default has a type mismatch when the child is an alias/struct (interning crash) or a 1-tuple (PHI {i32} vs i32) — the default and the unwrapped payload must share the exact merge TypeId. Verify each repro produces the expected value (99, 7, and a clean ?(i32) coalesce); confirm the generic case across multiple instantiations. Add regressions under examples/optionals/09xx-.... (These reproduce on master, independent of the issue-0172 guard.)