← board

EmitLoadVar truncates a dyn-array handle to its element width

How it was found

Not by reading the code. IRParkManagedDyn ended with IRAppend(IR_LOAD_SYM, pdSym, ...) tagged Ord(tyPointer), which is what made this invisible: the IR tag says pointer, and an IR dump of the integer and string cases is structurally identical. The width does not come from the tag.

It cost about a day, spread over two sessions, and produced five refuted hypotheses in [[bug-a-a-fresh-array-result-has-no-owner-as-a-copy-or-concat-operand]] before the faulting instruction gave it away:

mov %r14,%rax ; add $0x0,%rax ; sub $0x8,%rax ; mov (%rax),%rax   <-- SIGSEGV
rax = 0xffffffffe7e00018

0xffffffff... is a sign-extended 32-bit value, not a heap address.

The mechanism

{ symtab.inc, AllocDynArray }
Syms[SymCount].TypeKind := elemType;    { <-- the ELEMENT kind }
Syms[SymCount].IsArray  := True;
Syms[SymCount].ArrLen   := -1;
{ symtab.inc:5400, EmitLoadVar }
tk  := Syms[idx].TypeKind;
sz  := TypeSlotSize(tk);
sgn := TypeSigned(tk);

array of Integersz=4, sgn=True → a 4-byte sign-extending load of an 8-byte handle.

Note IR_LOAD_SYM's call-arg loadability helper in ir_codegen.inc DOES check Syms[si].IsArray and treat ArrLen = -1 as "a plain pointer read". So one side of this already knows a dyn-array handle is pointer-sized and the other does not — two mechanisms serving one concept, which is the smell normalise-dont-special-case names.

Why nothing is broken today

Every handle read in the tree goes through IR_LEA, which is the correct idiom and has no such dependence. The park was the one caller that used IR_LOAD_SYM on an array symbol, and it has been changed to IR_LEA.

So this is a guard that does not exist, not a live wrong answer: the next caller to reach an array symbol through IR_LOAD_SYM gets a silently truncated pointer on 64-bit only, and will spend the same day.

Shape of a fix

In EmitLoadVar, before the width is taken:

if Syms[idx].IsArray and (Syms[idx].ArrLen = -1) then
begin tk := tyPointer; sz := TypeSlotSize(tyPointer); sgn := False; end;

Cheap, and it can only turn a wrong width into a right one — no current caller depends on the truncation, because a truncated handle is never useful. But EmitLoadVar is a hot shared path on every backend, so it wants its own landing and its own gate rather than riding along with the leak fix.

Worth checking in the same pass whether the other backends' load emitters (EmitLoadVarA64, EmitLoadVarArm32, EmitLoadVarXtensa, riscv32) have the same shape. They are 32-bit or have their own paths, so the bug does not BITE there, but the missing check probably reads the same — and "fixed one arm of a double case, grep for the sibling" applies.

Log