Skip to content

Copy composite elements into aggregate literals - #46

Merged
wtholliday merged 2 commits into
mainfrom
fix-array-of-composites
Aug 26, 2026
Merged

Copy composite elements into aggregate literals#46
wtholliday merged 2 commits into
mainfrom
fix-array-of-composites

Conversation

@wtholliday

Copy link
Copy Markdown
Collaborator

Fixes #45.

Root cause

Composite values (closures, structs, arrays, slices) are represented by the address of their storage. Every backend's aggregate-literal codegen stored that address as if it were the element value, so [(|i: i32| x + 1.0), (|i: i32| x * 3.0)] wrote pointers-to-temporaries — or, on the register VM, the low 32 bits of one — into 16-byte element slots instead of copying the fat closure pointers in. Reading an element back then interpreted garbage as a function index.

Calling the same lambda through a plain local variable works because var initialization already goes through a proper copy; only aggregate construction was storing the pointer.

Changes

One per backend, all in the element-store path:

  • src/jit.rs — new store_element/is_indirect; memcpy for indirect elements instead of stack_store, used by ArrayLiteral, Array fill, and Tuple. Float32x4 stays a SIMD register value rather than becoming pointer-represented.
  • src/vm_codegen.rsemit_store_offset emits IAddImm + MemCopy for pointer types, matching what emit_store already did in the no-offset case.
  • src/stack_codegen.rs — replaces the TODO/HACK block that fell through to an offset-less MemCopy. MemCopy has no offset operand, so the new emit_dest_addr folds the offset into the destination address before the value is pushed; the four aggregate-literal callers use it.
  • src/llvm_jit.rs — new store_element, used by array literals, array fills, tuples, and struct literals.

Two more cases fall out of the same defect and are fixed here too:

  • nested array literals ([[1, 2], [3, 4]]) were wrong on every backend
  • a struct literal with a function-typed field (Op(f: add_one)) panicked the register VM with the same out-of-bounds index

The stack backend's float argument/return bridging that #45 also mentions was already fixed by #43; this branch is based on that.

Tests

  • tests/cases/lambdas/lambda_array.lyte — the issue's repro, the f32-parameter variant, and a fill-array of closures. This reaches the indirect-expression CallClosure site that Bridge f32/f64 across closure calls on the stack backend #43's test could not.
  • tests/cases/arrays/array_literal_composite.lyte — struct elements and nested-array elements, literal and fill.
  • tests/cases/lambdas/func_ref_struct.lyte — adds the struct-literal form alongside the existing field-assignment form.

./test.sh passes: 382 lib + 8 CLI (golden suite across jit / vm / asm / stack / llvm) + 21 lsp, 0 failures.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DeNzkFkzMbdH3XPzitDmVa

wtholliday and others added 2 commits August 26, 2026 10:58
Composite values (closures, structs, arrays, slices) are represented by
the address of their storage. Every backend's aggregate-literal codegen
stored that address as if it were the element value, so an array of
lambdas wrote pointers to temporaries — or, on the register VM, the low
32 bits of one — into 16-byte element slots instead of copying the fat
closure pointers in. Reading an element back then interpreted garbage as
a function index: segfaults on the Cranelift and arm64 backends, an
out-of-bounds panic on the VM, a failed assert on the stack backend.

Fix the element-store path in each backend:

  - jit: new store_element/is_indirect; memcpy indirect elements instead
    of stack_store, in array literals, array fills, and tuples. f32x4
    stays a SIMD register value.
  - vm_codegen: emit_store_offset emits IAddImm + MemCopy for pointer
    types, matching what emit_store already did without an offset.
  - stack_codegen: replace the TODO/HACK block that fell through to an
    offset-less MemCopy. MemCopy has no offset operand, so the new
    emit_dest_addr folds the offset into the destination address before
    the value is pushed.
  - llvm_jit: new store_element, used by array literals, array fills,
    tuples, and struct literals.

Two more cases fall out of the same defect: nested array literals
([[1,2],[3,4]]) were broken on every backend, and a struct literal with
a function-typed field (Op(f: add_one)) panicked the register VM.

Fixes #45.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DeNzkFkzMbdH3XPzitDmVa
Three follow-ups from review of the composite-element fix.

The register VM's new composite-store path computed `base + offset` with
IAddImm, whose immediate is packed as an i8 with no WIDE fallback, so any
element or field past byte offset 127 was written to the wrong address —
`[P(a: 7, b: 8); 20]` read back garbage at index 16, and a struct literal
with a field past offset 160 smashed the frame. Route every byte-offset
address computation in vm_codegen through emit_offset_addr, which
materializes the constant when it doesn't fit, and add a debug assertion
to the encoder so the truncation can't come back silently. This also
fixes the same latent bug in emit_load_offset's Bool/Int8/UInt8 path,
which was reachable before this branch.

f32x4 satisfies is_ptr() but rides in a register rather than as an
address, so aggregate member access has to ask "is this indirect?".
jit.rs grew is_indirect for that in the previous commit but left the
struct-literal store and the field/element read sites on raw is_ptr(),
which handed a SIMD value to gen_copy as a source address and tripped
the Cranelift verifier. Promote is_indirect to a free function, use it at
all four sites, and fold the two hand-rolled copies of the same rule
(returns_via_pointer, the globals path) into it. Mirror the whole thing
in llvm_jit, which had the identical divergence. f32x4 inside a struct or
array now works on all five backends; it previously panicked the
Cranelift backend and mis-read on LLVM.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DeNzkFkzMbdH3XPzitDmVa
@wtholliday

Copy link
Copy Markdown
Collaborator Author

Follow-up commit b09f490 addresses all three review findings.

1 & 2 — IAddImm immediate truncation (HIGH). Confirmed. tags::IADD_IMM packs C as a signed byte and, unlike Store32Off/MemCopy, has no _WIDE form. My composite-store path used it for an unbounded byte offset, so element 16 of [P(a: 7, b: 8); 20] (offset 128) wrapped to −128; the struct-literal case corrupted the frame.

Fixed in codegen rather than the encoder: an encoder-level rewrite to LoadImm + IAdd would need a scratch register, and reusing dst is unsafe once coalescing can make dst == src. New emit_offset_addr in vm_codegen.rs materializes the constant when the offset is outside i8, and all six byte-offset IAddImm sites now go through it — including emit_load_offset's Bool/Int8/UInt8 path, which was independently broken before this branch (struct B { pad: [i32; 40], f: bool } panicked the VM). Added a debug_assert! in the encoder so the truncation can't silently return; the whole suite runs in debug, so it is live in CI.

Adding an IADD_IMM_WIDE would also have worked but means a new entry in the hand-written arm64 dispatch table in vm_arm64.S — more surface than this bug warrants.

3 — f32x4 in aggregates (LOW, pre-existing). Also confirmed, and worth closing here since the PR introduces the right predicate. is_indirect is now a free function in jit.rs, used at the struct-literal store and the struct-field / tuple-field / array-element read sites, with the two hand-rolled copies of the same rule (returns_via_pointer, the globals path) folded into it. llvm_jit.rs had the identical divergence and got the same treatment.

The reviewer flagged the read side as out of scope, but fixing only the store would have turned a loud verifier panic into a quieter wrong answer, so both sides are done: f32x4 inside a struct or an array now works on all five backends, where it previously ICE'd Cranelift and mis-read on LLVM.

Tests. tests/cases/arrays/array_large_composite_offset.lyte (element at offset 128, struct field past 160) and tests/cases/simd/f32x4_in_aggregate.lyte. Both fail on the parent commit for the reasons above. Full suite green: 382 lib + 8 CLI (jit / vm / asm / stack / llvm) + 21 lsp.

Not changed: gen_copy/gen_eq still key off raw is_ptr(). They are reached by paths that never see a bare f32x4, and widening them is a bigger change than this PR should carry.

@wtholliday
wtholliday merged commit cb909f2 into main Aug 26, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Arrays of lambdas produce wrong results or crashes on every backend

1 participant