fix: bindingless if/while/and/or over optional reads has_value (issue 0164)

lowerIfExpr emitted optional_has_value only for the binding form; a bare
'if opt' passed the raw {T,i1} aggregate to condBr, where emitCondBr's
catch-all struct arm silently folded it to 'i1 true' (structs always
truthy) — a silent miscompile that took the present-branch for null
optionals. while / and / or shared the same defect.

Reduce bindingless optional conditions to optional_has_value in
lowerIfExpr/lowerWhile and via a new lowerBoolCondition helper for and/or
operands. Replace the silent-true emitCondBr arm with a lowering-time
diagnostic (checkConditionType/isValidConditionType) rejecting conditions
whose type isn't bool/integer/pointer/optional; the backend @panic is now
an unreachable tripwire.

Regressions: examples/optionals/0908..0910 + diagnostics/1194 (negative).
Verified by 3+3 adversarial reviews.

Filed adjacent bugs found during review: 0168 (array-of-optionals element
load), 0169 (optional->bool coercion), 0170 (closure-optional layout).
This commit is contained in:
agra
2026-06-22 21:04:05 +03:00
parent 2637ae98a5
commit 3e8d003e3d
24 changed files with 418 additions and 13 deletions

View File

@@ -0,0 +1,47 @@
# 0170 — closure optional `?(() -> ...)` alloca is sized one word, truncating the `{fn,env}` closure value
## Symptom
An optional of a closure (`?(() -> void)`, `?Fn`) is mis-laid-out: the optional
alloca is typed `{ {ptr}, i1 }` (one pointer word + flag) but a closure value is
two words `{ {ptr, ptr}, i1 }` = `{ {fn, env}, has }`. Storing the two-word
closure constant into the one-word-typed alloca truncates it; reading the
has_value flag (`extractvalue …, 1`) then returns the closure's `env` word
(commonly null → 0 → `i1 false`) instead of the real flag. A PRESENT closure
optional therefore tests as ABSENT. Silent miscompile.
Independent of issue 0164 (the condition-reduction fix): `g != null` is also
wrong, so it's a layout/representation bug, not a truthiness bug.
## Reproduction
```sx
#import "modules/std.sx";
main :: () {
g : ?(() -> void) = () { print("called\n"); };
if g { print("present\n"); } else { print("absent\n"); } // prints "absent" — WRONG
if g != null { print("nn-present\n"); } else { print("nn-absent\n"); } // "nn-absent" — WRONG
}
```
Expected: `present` / `nn-present` (a freshly-assigned closure is present).
Observed: `absent` / `nn-absent`, exit 0.
## Investigation prompt
The optional-of-closure type lowering uses the wrong child layout — it sizes the
optional payload as a single pointer rather than the closure's `{fn, env}` fat
value. Suspect the optional type lowering / `toLLVMType` for `?Closure`
(`src/ir/types.zig` optional lowering + `src/backend/llvm/types.zig`), and the
`optional_wrap` / has_value codegen (`src/backend/llvm/ops.zig`
`emitOptionalWrap` / `emitOptionalHasValue`) — the payload offset/size and the
has_value flag offset must use the closure's full 2-word size. Compare against
the `?Closure` handling the comptime VM already documents (issue 0162's fix
notes a `?Closure` sentinel/`{fn,env}` layout). Decide the canonical runtime
repr (sentinel fn-ptr-null vs discriminated `{ {fn,env}, i1 }`) and make alloca
size, store, and has_value read all agree.
Verify: the repro prints `present` / `nn-present`; calling through the unwrapped
closure (`g!()`) prints `called`; a null `?Fn` tests absent. Add an
`examples/optionals/09xx-closure-optional.sx` regression (present + null +
call-through).