Files
sx/issues/0185-binop-optional-operand-silent-unwrap.md
agra 820cd62fa1 docs: file issues 0185-0188
File four issue write-ups discovered alongside the 0179 work:
- 0185: binary-op operand auto-unwrap silently miscompiles a NULL ?T
- 0186: closure VALUE call does not coerce arg to ?T parameter
- 0187: lambda with inferred return type + block body with early returns
  mis-infers its return type
- 0188: closure-VALUE calls skip argument validation (arity + tuple spread)
2026-06-25 13:57:56 +03:00

4.8 KiB

0185 — binary-op operand auto-unwrap silently miscompiles a NULL ?T operand to garbage

RESOLVED. Root cause as diagnosed below: lowerBinaryOp (src/ir/lower/expr.zig) auto-unwrapped optional operands UNCONDITIONALLY. Fix: gate both operand unwraps on flow narrowing (the same narrowed_refs mechanism issue 0179 introduced) — an un-narrowed ?T operand is rejected via the new diagOptionalOperand; a guard-narrowed operand still unwraps. == null / != null presence tests are unaffected (they return early before the auto-unwrap). While fixing, an adversarial review of 0179 surfaced a real soundness hole: narrowed_refs (keyed by per-function Ref index) leaked into nested bodies whose Ref space overlaps (closure literals, generic/pack/comptime monomorphization, AND — caught by an INDEPENDENT second-pass review — the JNI native-method body path, where residue leaked between consecutive #jni_main method stubs), letting an outer narrowed Ref falsely match a nested Ref. Closed with Lowering.NarrowGuard (save/clear/restore around each such nested body): lowerLambda (closure.zig), monomorphizeFunction (generic.zig), createComptimeFunctionWithPrelude (comptime.zig), monomorphizePackFn (pack.zig), synthesizeJniMainStub (ffi.zig). FnBodyReentry + the explicit clear in lowerFunction (decl.zig) cover the rest. Regressions: examples/optionals/0921-optionals-binop-narrowing.sx + examples/optionals/0922-optionals-binop-no-implicit-unwrap.sx.

Discovered (separate, NOT fixed here): calling a closure VALUE/variable whose parameter is ?T does not coerce the argument to the param type — a concrete 7 arrives absent and null emits ptr null against a {T,i1} param (LLVM verifier failure). Pre-existing, orthogonal to optional unwrap. Filed as issue 0186.

Symptom

An arithmetic / comparison binary op with an OPTIONAL operand (a + b, a < b, etc.) unconditionally unwraps the optional's payload — for a PRESENT optional it yields the value, for a NULL optional it yields the zero/garbage payload with NO diagnostic. Silent miscompile, same spirit as issue 0179 but a DIFFERENT code path: this auto-unwrap is in the binary-op lowering, NOT the classify / coerceMode coercion ladder that 0179 fixed.

Per specs.md §Optional Types, the only legal ways to extract T from ?T are ! / ?? / if v := opt / pattern match / flow-sensitive narrowing after a != null guard. There is no implicit unwrap-at-an-operand-position; a null operand must not silently become its zero payload.

Reproduction

#import "modules/std.sx";
main :: () {
  a : ?i64 = null;
  b : i64 = 10;
  c := a + b;            // prints "c = 10" (0 + 10) — silent miscompile, no diagnostic
  print("c = {}\n", c);
}

Expected: a compile error like 0179's (cannot use a value of type '?i64' where 'i64' is expected: an optional does not implicitly unwrap; ...), unless a is proven present by a != null guard (flow narrowing), in which case the unwrap is sound.

Root cause

src/ir/lower/expr.zig (lowerBinaryOp, ~line 3210, "Auto-unwrap optional operands for arithmetic/comparison"): both operand arms do an unconditional .optional_unwrap when the operand type is .optional, never reading the has_value flag and never consulting flow narrowing. This is the operand-side analogue of the coercion-side bug fixed in 0179.

Investigation prompt

Gate the binary-op operand auto-unwrap on the SAME flow-narrowing mechanism 0179 introduced (Lowering.narrowed / narrowed_refs, see src/ir/lower/control_flow.zig + coerceMode's .optional_unwrap arm in src/ir/lower/coerce.zig):

  1. In lowerBinaryOp, when an operand is ?T, only auto-unwrap it when its lowered Ref is in self.narrowed_refs (proven present); otherwise emit the same loud diagnostic 0179 uses and skip the silent unwrap. The operand Ref is produced by lowerExpr(bop.lhs/rhs), so a guard-narrowed local is already tagged into narrowed_refs by lowerIdentifier — the gate is a narrowed_refs.contains(operand) check.
  2. Decide the semantics for a present-but-mixed case (e.g. ?i64 + i64): the result stays i64 (unwrap the optional operand) only when narrowed, as above. Confirm comparisons (==/!=) against null are unaffected — those are presence tests, not operand unwraps, and must keep working.
  3. Verify: the repro above must become a compile error; a guarded if a != null { c := a + b; } must still compute a + b correctly; the existing examples/optionals/0900-optionals-optionals.sx guard2 (return a + b after a compound == null or guard) must still pass.

Add a positive regression (guarded arithmetic) + a negative regression (unguarded ?T operand rejected), mirroring 0179's examples/optionals/0919 / 0920.