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)
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 samenarrowed_refsmechanism issue 0179 introduced) — an un-narrowed?Toperand is rejected via the newdiagOptionalOperand; a guard-narrowed operand still unwraps.== null/!= nullpresence 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-functionRefindex) leaked into nested bodies whoseRefspace 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_mainmethod stubs), letting an outer narrowedReffalsely match a nestedRef. Closed withLowering.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
?Tdoes not coerce the argument to the param type — a concrete7arrives absent andnullemitsptr nullagainst 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):
- In
lowerBinaryOp, when an operand is?T, only auto-unwrap it when its loweredRefis inself.narrowed_refs(proven present); otherwise emit the same loud diagnostic 0179 uses and skip the silent unwrap. The operandRefis produced bylowerExpr(bop.lhs/rhs), so a guard-narrowed local is already tagged intonarrowed_refsbylowerIdentifier— the gate is anarrowed_refs.contains(operand)check. - Decide the semantics for a present-but-mixed case (e.g.
?i64 + i64): the result staysi64(unwrap the optional operand) only when narrowed, as above. Confirm comparisons (==/!=) againstnullare unaffected — those are presence tests, not operand unwraps, and must keep working. - Verify: the repro above must become a compile error; a guarded
if a != null { c := a + b; }must still computea + bcorrectly; the existingexamples/optionals/0900-optionals-optionals.sxguard2(return a + bafter a compound== null orguard) must still pass.
Add a positive regression (guarded arithmetic) + a negative regression
(unguarded ?T operand rejected), mirroring 0179's
examples/optionals/0919 / 0920.