fix: comptime reg->value bridge for array-in-aggregate + clean abort on comptime-init failure (issue 0167)

(C) regToValue (comptime_vm.zig) gained no array arm, so a #run returning
an aggregate containing an array bailed 'reg->value: aggregate shape not
bridged yet'. Add an .array arm: read N elements at stride
typeSizeBytes(elem) from the array address, bridge each recursively via
regToValue -> an .aggregate Value (serializeAggregateValue already emits
arrays). Composes with struct fields, nested arrays, array-of-structs,
and the ?Arr optional payload; unbridgeable elements bail loudly.

(E) A global failing #run proceeded into LLVM emission and panicked
'unresolved type reached LLVM emission' when the unresolved const was
used. Add 'if (self.comptime_failed) return;' in emit() after Pass 0 so
it aborts cleanly (exit 1, the comptime diagnostic) across run/ir/build.

Regression: examples/comptime/0644-comptime-run-array-aggregate.sx.
Verified by 3 adversarial reviews, suite 793/0. Filed separate bugs found
during review: 0181 (optional-chain ?. to array field + index panics),
0182 (body-local #run unbridged silently miscompiles).
This commit is contained in:
agra
2026-06-23 11:34:22 +03:00
parent 555ccdc024
commit fa7c07faf8
9 changed files with 246 additions and 0 deletions

View File

@@ -0,0 +1,62 @@
// A `#run` (comptime const init) whose function returns an aggregate that
// CONTAINS AN ARRAY — or an array directly — evaluates: the comptime VM's
// reg→value bridge reads the array's elements out of comptime memory and
// produces a `Value` array the LLVM serializer emits as a constant.
//
// Regression (issue 0167 C): the array-in-aggregate shape used to bail with
// `reg→value: aggregate shape not bridged yet`.
// Covers: struct-with-array-field, array-of-structs, nested array `[2][2]`,
// a direct `[N]T` return, and the `?Arr` optional payload (composes with the
// optional bridge arm) unwrapped via `!`.
#import "modules/std.sx";
Arr3 :: struct { xs: [3]i64; }
Pt :: struct { x: i64; y: i64; }
Box :: struct { items: [2]Pt; }
Mat :: struct { g: [2][2]i64; }
mk_struct :: () -> Arr3 {
r : Arr3 = ---;
r.xs[0] = 1; r.xs[1] = 2; r.xs[2] = 3;
return r;
}
mk_aos :: () -> Box {
r : Box = ---;
r.items[0].x = 1; r.items[0].y = 2;
r.items[1].x = 3; r.items[1].y = 4;
return r;
}
mk_nested :: () -> Mat {
r : Mat = ---;
r.g[0][0] = 1; r.g[0][1] = 2;
r.g[1][0] = 3; r.g[1][1] = 4;
return r;
}
mk_direct :: () -> [3]i64 {
r : [3]i64 = ---;
r[0] = 7; r[1] = 8; r[2] = 9;
return r;
}
mk_opt :: () -> ?Arr3 {
r : Arr3 = .{ xs = .[10, 20, 30] };
return r;
}
G :: #run mk_struct(); // struct { [3]i64 }
B :: #run mk_aos(); // struct { [2]Pt }
M :: #run mk_nested(); // struct { [2][2]i64 }
D :: #run mk_direct(); // [3]i64
A :: #run mk_opt(); // ?Arr3
main :: () {
print("{} {} {}\n", G.xs[0], G.xs[1], G.xs[2]); // 1 2 3
print("{} {} {} {}\n", B.items[0].x, B.items[0].y, B.items[1].x, B.items[1].y); // 1 2 3 4
print("{} {} {} {}\n", M.g[0][0], M.g[0][1], M.g[1][0], M.g[1][1]); // 1 2 3 4
print("{} {} {}\n", D[0], D[1], D[2]); // 7 8 9
print("{}\n", A!.xs[0]); // 10
}

View File

@@ -0,0 +1,5 @@
1 2 3
1 2 3 4
1 2 3 4
7 8 9
10

View File

@@ -1,5 +1,22 @@
# 0167 — comptime `#run` returning an aggregate that contains an array fails the reg→value bridge (+ unclean recovery)
> **RESOLVED.** (C) Added an `.array` arm to `regToValue` in
> `src/ir/comptime_vm.zig`: reads N elements at stride `typeSizeBytes(elem)` from
> the array's address and recursively bridges each via `regToValue(elem_ty)` →
> an `.aggregate` Value (`serializeAggregateValue` already handles arrays).
> Composes with struct-field walks, nested arrays, array-of-structs, and the
> `?Arr` optional payload; unbridgeable element types bail loudly. (E) Added
> `if (self.comptime_failed) return;` in `emit()` (`src/ir/emit_llvm.zig`) after
> Pass 0, so a GLOBAL failing `#run` aborts cleanly (exit 1, the `comptime init
> of 'X' failed: …` diagnostic) instead of panicking `unresolved type reached
> LLVM emission` — verified across `sx run`/`ir`/`build`. Regression:
> `examples/comptime/0644-comptime-run-array-aggregate.sx`. Verified by 3
> adversarial reviews; suite 793/0. The issue's (E) repro `A?.xs[0]` now routes
> to two SEPARATE pre-existing bugs filed during review: **0181** (optional-chain
> `?.` to an array field then `[idx]` → unresolved panic, pure-runtime) and
> **0182** (body-local `#run` of an unbridged shape silently miscompiles —
> `lowerInlineComptime` doesn't set `comptime_failed`). Both are out of 0167's
> reg→value-bridge scope.
## Symptom
Two related defects:

View File

@@ -0,0 +1,86 @@
# 0181 — `?.`-chain (and `?`-postfix) on an optional whose child struct contains an ARRAY field panics `unresolved type reached LLVM emission`
## Symptom
A `?.` optional-chain access (or the `?` optional-test postfix used in a
member-access chain) on a value of type `?S`, where `S` is a struct that
contains an **array field**, panics:
`thread … panic: unresolved type reached LLVM emission — a type resolution
failure was not diagnosed/aborted` (exit 134).
The same chain on `?S` where `S` has **no** array field works fine, and the
`!` force-unwrap chain (`opt!.field`) on the same array-containing `?S` works
fine. The defect is specific to the `?`/`?.` operator's receiver-type inference
when the optional's child struct contains an array field — that receiver types
as `.unresolved` and reaches LLVM. This is a pure **runtime** lowering bug: no
`#run`/comptime is involved.
Observed vs expected:
- Observed: SIGABRT panic (exit 134) at `src/backend/llvm/types.zig:196`
(`toLLVMTypeInfo` `.unresolved` arm), reached from `declareFunction`'s
`param.ty` lowering of a synthesized accessor.
- Expected: the chain evaluates (prints the field), exactly as the `!`-unwrap
and the non-array `?.` forms already do.
## Reproduction
Pure runtime, no `#run` — panics:
```sx
#import "modules/std.sx";
Arr3 :: struct { xs: [3]i64; }
mk :: () -> ?Arr3 { r : Arr3 = .{ xs = .[1,2,3] }; return r; }
main :: () { print("{}\n", mk()?.xs[0] ?? 99); } // PANIC exit 134
```
Control A — same chain, child struct has NO array field — WORKS, prints `7`:
```sx
#import "modules/std.sx";
Pt :: struct { x: i64; }
mk :: () -> ?Pt { return Pt.{ x = 7 }; }
main :: () { print("{}\n", mk()?.x ?? 99); }
```
Control B — same array-containing `?Arr3`, but `!` force-unwrap — WORKS, prints `1`:
```sx
#import "modules/std.sx";
Arr3 :: struct { xs: [3]i64; }
mk :: () -> ?Arr3 { r : Arr3 = .{ xs = .[1,2,3] }; return r; }
main :: () { print("{}\n", mk()!.xs[0]); }
```
(The issue 0167 (E) repro `A?.xs[0]` hit this same bug — it used `?` where `!`
was meant; with `!` the comptime `#run ?Arr3` case evaluates. So this is the
*residual* defect that 0167's (E) repro tripped, distinct from 0167 (C)/(E),
both of which are fixed.)
## Investigation prompt
The `?` optional-chaining / optional-test path synthesizes an accessor whose
receiver (the unwrapped child) types as `.unresolved` specifically when the
child is a struct containing an array field — mirroring the already-fixed
issue-0101 `!`-unwrap bug (`inferExprType` had no force_unwrap arm → receiver
typed `.unresolved`). The `!` path was fixed (see
`examples/optionals/0905-optionals-unwrap-field-chain.sx`); the `?`/`?.` path
has an analogous gap that only surfaces for an array-containing child (a
plain-scalar/string child happens to resolve).
Suspected area: `src/ir/lower.zig` `inferExprType` (grep for the optional-chain
/ `?` postfix / `safe_nav` handling) and/or `src/ir/lower/` accessor-chain
lowering — find where the `?`-chain receiver type is computed and why an
array-containing struct child yields `.unresolved`. Compare against the working
`!`-unwrap arm (issue 0101 fix) and apply the same receiver-type flow.
Verification: the first repro above prints `1` and exits 0; controls A and B
still pass; add a regression under `examples/optionals/` covering `?.`-chain on
an array-containing `?S` (field read + `?? default`). Confirm
`examples/comptime/0644-comptime-run-array-aggregate.sx` (issue 0167) still
passes.
## Provenance
Discovered while implementing issue 0167 (C: comptime reg→value array-in-
aggregate bridge; E: clean-abort on comptime-init failure). 0167 (C) and (E)
are FIXED and verified; the `?Arr3` access form in 0167's (E) repro tripped this
SEPARATE, pre-existing runtime lowering bug (confirmed reproducible on clean
`HEAD` with no `#run`).

View File

@@ -0,0 +1,46 @@
# 0182 — a body-local `#run` of an unbridged-shape return silently miscompiles (no abort, exit 0 garbage)
## Symptom
A `#run` const declared INSIDE a function body, whose comptime function returns a
shape the comptime VM cannot bridge to a host `Value` (e.g. `[2][]i64` — array of
slices, or a struct containing a slice that can't be const-materialized), does
NOT fail the build. Unlike a GLOBAL `#run` const (which sets `comptime_failed`
and aborts cleanly with `error: comptime init of 'X' failed: ...` — see issue
0167's recovery guard), the body-local form leaves a RUNTIME call to the comptime
function in place, executing it at runtime over uninitialized (`---`) storage →
garbage, exit 0, NO diagnostic. Silent miscompile (no-silent-fallback violation).
Found during adversarial review of issue 0167.
## Reproduction
```sx
#import "modules/std.sx";
mk :: () -> [2][]i64 { a : []i64 = ---; r : [2][]i64 = ---; r[0] = a; r[1] = a; return r; }
main :: () {
L :: #run mk(); // body-local #run of an unbridgeable shape
print("{}\n", L[0][0]); // prints garbage, exit 0 — should be a clean comptime error
}
```
Expected: a clean `error: comptime init of 'L' failed: ...` (exit 1), the same as
the equivalent GLOBAL `#run` const. Observed: garbage output, exit 0, no
diagnostic. (Even an UNUSED body-local `L :: #run mk()` silently "succeeds"
instead of reporting the bridge failure.)
## Investigation prompt
`src/ir/lower/comptime.zig` `lowerInlineComptime` (~line 337) emits a RUNTIME
`call` to the comptime function and relies on the interpreter to const-fold it.
When the fold/`regToValue` bridge cannot materialize the result, the runtime call
is left in place rather than failing — so the comptime fn runs at runtime over
`---` storage. The fix: when a body-local `#run` const-fold fails to materialize
(the same `regToValue` bail that a global `#run` reports), it must set
`comptime_failed` / emit the `comptime init of 'X' failed: <reason>` diagnostic
and abort, exactly like the global-init path (`failGlobalInit` in
`src/ir/emit_llvm.zig`), NOT silently fall back to a runtime call. Mirror the
global path's loud failure. Verify: the repro exits 1 with the comptime
diagnostic; a body-local `#run` of a BRIDGEABLE shape (scalar, struct, array,
`?Arr`) still works (don't regress the common case); an unused failing body-local
`#run` also aborts. Add an `examples/comptime/06xx-...` (or a diagnostics) test.

View File

@@ -2236,6 +2236,24 @@ pub const Vm = struct {
}
return .{ .aggregate = out };
}
if (info == .array) {
// `[N]E` is held by-address as N contiguous `E` slots at
// stride `sizeof(E)`. Bridge each element via `regToValue`
// recursively (so a nested array / array-of-struct / array
// inside a struct all compose), producing an `.aggregate`
// Value whose serializer arm (`serializeAggregateValue`'s
// `.array` case) emits an `LLVMConstArray2`.
const elem_ty = info.array.element;
const len: usize = @intCast(info.array.length);
const stride: Addr = @intCast(table.typeSizeBytes(elem_ty));
const out = alloc.alloc(Value, len) catch return self.failMsg("reg→value: out of memory (array)");
for (0..len) |i| {
const elem_addr = reg + @as(Addr, @intCast(i)) * stride;
const er = try self.readField(table, elem_addr, elem_ty);
out[i] = try self.regToValue(alloc, table, er, elem_ty);
}
return .{ .aggregate = out };
}
if (info == .optional) {
// Only the `{ payload@0, has_value@sizeof(child) }` aggregate
// shape lands here — a pointer-child optional is a word and

View File

@@ -391,6 +391,16 @@ pub const LLVMEmitter = struct {
// Pass 0.5: Run comptime side-effect functions (#run expr; at top level)
self.runComptimeSideEffects();
// A comptime/global init failure (e.g. an unbridgeable `#run` result)
// sets `comptime_failed` AND leaves the failed const's type as the
// `.unresolved` sentinel. The driver converts `comptime_failed` into a
// clean exit-1 *after* emit() returns — but the remaining passes
// (declare/emit function bodies that reference the now-unresolved const)
// would `@panic("unresolved type reached LLVM emission")` first. Abort
// emission here so the failure surfaces as the printed diagnostic +
// clean exit, never the panic.
if (self.comptime_failed) return;
// Pass 1: Declare all functions (so calls can reference them)
for (self.ir_mod.functions.items, 0..) |func, i| {
self.declareFunction(&func, @intCast(i));