Files
sx/issues/0170-closure-optional-layout-truncated.md
agra 3e8d003e3d 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).
2026-06-22 21:04:05 +03:00

2.2 KiB

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

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