← board

A nil assignment to a dynamic-array field is lowered as a record zero

The boundary

var  ra: array of TR;   ra := nil;     { plain VARIABLE  — always worked  }
TC.IA: array of Integer; IA := nil;    { scalar element  — always worked  }
TC.RA: array of TR;      RA := nil;    { record element  — refused, then crashed }

fpc 3.2.2 -Mobjfpc sets length 0 for all three.

The two defects, and why the order matters

1. The false reject. AssignSideKind reads the destination's kind off ASTTk[node], and for a dyn-array field that is the ELEMENT's kind. The AN_IDENT arm bails on Syms[si].IsArray with the comment "the kind is the ELEMENT's"; the arm added later for fields, elements and derefs never got the same bail. So the check described an array of TR destination as a record and refused legal Pascal — the direction that function's own header calls the worse one: "a false REJECT of working code is a worse defect than the false accept being fixed here."

2. The miscompile underneath it. ASTNodeIsWholeArray opens by explaining that an array's TypeKind is its element's and that a pass deciding from the kind alone cannot tell arr := other from arr[i] := other — and then answers only for AN_IDENT. A dyn-array field is a whole array and it said no, so the store took the "any record-shaped destination := nil" arm: zero-fill RecSize(TR) = 4 bytes at the field's address, over an 8-byte array handle.

ACCIDENTAL COVER — the part worth carrying

The type error was a refusal, so nothing had ever reached the bad lowering. Fixing the false reject is what exposed the segfault; had they been worked in the other order the crash would have looked like a regression introduced by the whole-array widening. Neither is provable alone, which is why both are in one commit.

The general shape: a false reject can be load-bearing. "We refuse this legal construct" is not a bounded cost — behind it sits an unknown amount of lowering that no instrument has ever exercised, and the refusal is what keeps it off every one of them.

Resolution 2026-09-06

The fixture

test/test_nil_assigned_to_a_dynamic_array_field.pas, byte-identical to fpc. The int and out rows already worked and are present so the file cannot read "arrays now work" as "records now work". The mp row must not move: a method-pointer field is record-SHAPED and OnHit := nil has to keep the zero-fill arm (bug-a-assigning-nil-to-a-method-pointer-segfaults) — widening the whole-array predicate is exactly the change that could steal it, so it is asserted here rather than left to the ticket that owns it.

Log