← board

Managed-record function results into a REUSED destination leak the old payload

The final layer of [[bug-a-runtime-variant-heap-grows-unbounded]] (three other layers fixed 2026-07-22). Generic repro, plain Pascal, x86-64 (ticket notes say the shape grows on aarch64 too):

type TR = record n: Integer; xs: array of Integer; end;
function mk(v: Integer): TR;
begin SetLength(Result.xs, 4); Result.n := v; end;
function add2(const a, b: TR): TR;
begin SetLength(Result.xs, 4); Result.n := a.n + b.n; end;
var r: TR; k: Integer;
begin
  for k := 1 to 200000 do
    r := add2(mk(k), mk(1));    { 23.4 MB peak — ~117 B leaked per iteration }
end.

One call per fresh frame is FLAT (prologue zero + epilogue cleanup cover it). The leak needs the call site to RE-EXECUTE within one frame (a loop): the callee's aggregate-return epilogue RAW-copies Result into the caller's hidden dest temp / assignment target (x86-64 rep movsb, riscv32 PXXMemMove), so a reused dest's previous dynarray/string handles are overwritten without release — one orphan per managed field per call.

Why it matters

promoint's whole bignum tier is built on TBig (record + dynarray limbs) returned by value from nested helpers — every heap-tier promo BITWISE op leaks ~60-690 B (see the umbrella ticket's 5-line PromoInt64 repro), which is what still makes uforth's empty DO LOOP grow without bound (its ANS boundary check manufactures heap u64s each pass). Any user code with r := f(...) returning a managed record in a loop leaks the same way.

Fix shape (watch the alias trap)

Release the destination's managed fields before (or as part of) the aggregate result copy-in — BUT r := add2(r, x) may alias dest and argument, so a release BEFORE the call frees memory the callee still reads. Safe points:

Gate

lk23-style repro flat; d := m and 65535 / or heap-tier PromoInt64 loops flat; uforth : T 100000 0 DO LOOP ; T RSS bounded; make test + self-host byte-identical + cross for touched backends.

Log