Files
sx/issues/0180-null-coalesce-generic-and-nontrivial-optional-defects.md
agra 097d23d909 fix: presence-preserving optional->optional coercion (issue 0180)
The generic-?? wrong-fallback was not in lowerNullCoalesce: coercing
?A -> ?B (differing payload, e.g. the ?i32->?i64 call-arg coercion when
instantiating unwrap_or(99, ?i32)) routed through .optional_wrap, which
unconditionally unwrapped the source and re-wrapped as ALWAYS-PRESENT, so
a null became present-zero everywhere (args, returns, field init,
var-decl, ??). Add a CoercionPlan.optional_to_optional (conversions.zig)
+ a presence-preserving arm in coerceMode (coerce.zig): has_value ->
present: unwrap+coerce-child+wrap-present; absent: constNull(dst); merge
via a dst_ty block param. lowerVarDecl gains a !src_is_optional guard so
an annotated x : ?B = <?A> routes through the same arm (also makes
aggregate-payload var-decl ?[3]i64->?[]i64 / ?Concrete->?Protocol work).

Alias-optional struct-literal default already works (grouping + 0166);
a 1-tuple default ?(i32,) ?? 5 now emits a clean diagnostic instead of an
LLVM PHI abort (no implicit scalar->1-tuple coercion per spec).

Regressions: optionals/0916 (generic ??), 0917 (alias struct default),
0918 (var-decl optional->optional), diagnostics/1202 (1-tuple default) +
a conversions.test.zig unit test. Verified by 3 adversarial reviews,
suite 798/0.
2026-06-23 16:16:47 +03:00

4.2 KiB
Raw Blame History

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

RESOLVED. (1) The generic-??-wrong-fallback was NOT in lowerNullCoalesce — the real root cause was that an optional→optional coercion ?A → ?B (differing payload, e.g. the ?i32 → ?i64 call-arg coercion when instantiating unwrap_or(99, ?i32)) routed through .optional_wrap, which unconditionally UNWRAPPED the source and re-wrapped as ALWAYS-PRESENT — so a NULL became a present-zero everywhere (call args, returns, field init, var-decl, ??). Fix: a CoercionPlan.optional_to_optional (src/ir/conversions.zig) + a presence-preserving arm in coerceMode (src/ir/lower/coerce.zig): has_value → present: unwrap+coerce-child+wrap-present; absent: constNull(dst); merge via a dst_ty block param. lowerVarDecl (src/ir/lower/stmt.zig) gained a !src_is_optional guard so an annotated x : ?B = <?A> routes through the same arm (also makes aggregate-payload var-decl ?[3]i64 → ?[]i64 / ?Concrete → ?Protocol work). (2) The alias-optional struct-literal default already works (grouping + issue-0166 threading) — locked by regression. (3) ?(i32) is now a grouped ?i32 (issue-0177 grouping); a genuine 1-tuple default ?(i32,) ?? 5 emits a clean diagnostic (lowerNullCoalesce, src/ir/lower/expr.zig) instead of an LLVM PHI-verifier abort (no implicit scalar→1-tuple coercion per spec). Regressions: examples/optionals/0916 (generic ??), 0917 (alias struct default), 0918 (var-decl optional→optional present/null × widen/narrow/ int-float/array→slice/erasure), examples/diagnostics/1202 (1-tuple-default diagnostic) + a conversions.test.zig unit test. Verified by 3 adversarial reviews; suite 798/0.

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.)