← board

Nested dynamic arrays (array of array of T) segfault on cross targets

Symptom

A dynarray-of-dynarray built with per-row SetLength (no multidim sugar):

var a: array of array of Integer; i, j: Integer;
begin
  SetLength(a, 2);
  for i := 0 to 1 do SetLength(a[i], 3);
  for i := 0 to 1 do for j := 0 to 2 do a[i][j] := i*10 + j;
  writeln(Length(a), ' ', Length(a[0]), ' ', a[1][2]);
end.

The program uses no multidim-SetLength sugar, so this is a pre-existing nested-dynarray codegen gap on the 32-bit / cross backends, not related to the one-call SetLength(a, x, y) feature (which exposed it while smoke-testing).

Scope

SetLength(a[i], n) on a sub-array (an AN_INDEX lvalue whose value is itself a dynarray handle) — the inner-handle load/store, or the nested-index addressing, is wrong on the cross backends. Single-level dynarrays work cross (the torture test's other cases pass on arm32 in make test-arm32).

Direction

Bisect the cross IR_SETLEN / dynarray-handle path for a sub-array target: SetLength(a[i], n) must load the element slot a[i] (a handle) by address and resize that handle. Compare the x86-64 lowering (works) against arm32/aarch64. Likely the address-of-element-slot vs value-of-handle distinction (cf. the var- param IR_LEA scalar-deref class of bug on the 32-bit targets).

Acceptance

array of array of T (per-row and one-call multidim SetLength) runs correctly on arm32 / aarch64 / i386 / riscv32 (oracle == x86-64); cross regression test.

Fixed (2026-07-01, pin v136, i386/arm32/aarch64)

Root cause matched the "Direction" guess almost exactly: these three backends' IR_LEA codegen for a dynamic-array symbol always loaded the heap data pointer (the handle), with no gate on InLValueWrite — x86-64 already had this gate (write mode yields the slot's own address; read mode derefs to the handle). SetLength on an array symbol itself needs the SLOT's address to publish a new handle into. Crucially, this applies not just to the outer SetLength(a, n) but to SetLength(a[i], n) too: the root symbol a's own slot is still reached via a plain IR_LEA at the base of the nested-indexing chain (compiler/ir.inc's IRLowerAddress, "nested dynamic-array indexing" branch), so the same missing gate broke the per-row case directly.

Without the gate: SetLength(a, n) silently wrote through a's CURRENT (nil, before first allocation) handle instead of its slot — a silent no-op — and one level down, SetLength(a[i], n) computed a bogus small-integer "address" (nil base + i * elemSize) and crashed on any i > 0 (index 0 landed on the nil address itself — also a no-op, not a crash, which is why the FIRST per-row SetLength(a[0], ...) appeared to work while the second, SetLength(a[1], ...), segfaulted). Found by tracing actual pointer values through a temporarily-instrumented PXXDynSetLen/PXXDynArrayUnique on arm32 (plain Pascal runtime helpers, easy to add writeln to) rather than guessing from static code reading alone — several rounds of static analysis (elSize computation, COW/refcount logic, register clobbering) all checked out fine on paper and it took the actual runtime pointer values to find the real discrepancy.

Fixing IR_LEA alone regressed indexed WRITES into a plain array (g[i] := x, caught by a broader hand-written test before landing, not by the original repro): indexing always needs to read the array's own handle as a base regardless of the outer write intent, and x86-64 only gets this right because its IR_INDEX codegen follows up with a full COW-aware PXXDynArrayUnique call that overrides the write-mode IR_LEA result. These three backends have no COW yet ("v1: no COW", pre-existing, documented scope boundary), so IR_INDEX now does the minimal equivalent: deref once more when the base was a write-mode dynarray IR_LEA.

Verified against the x86-64 oracle for the original repro plus global, local, and by-ref-param (var a: TDynArr) dynamic-array scenarios; all existing i386/arm32/aarch64/riscv32 cross suites stay green (including dynarray, dynarray-field, setlen-str, setlen-varparam). test/test_nested_dynarray_setlen.pas added (oracle-comparison style, no hardcoded expected string, matching test_cross_dynarray.pas's existing convention), wired into all three targets' cross make test suites. Self-host byte-identical (these cross backends aren't exercised by self-hosting), full make test green.

riscv32/xtensa left untouched: their IR_LEA has the exact same missing InLValueWrite gate (confirmed by reading ir_codegen_riscv32.inc), but this precise scenario is blocked there today by an unrelated, pre-existing gap (error: managed aggregate locals not yet supported — riscv32 can't even compile the depth-2 repro yet), so there's no way to verify a fix without also lifting that separate limitation. Flagging for whoever picks up riscv32/xtensa dynamic-array support: apply the same InLValueWrite gate to IR_LEA (and the matching "deref once more" fixup to IR_INDEX) there too once the aggregate-locals gap is closed.