← board

An array of records-with-interface-fields leaks the interfaces

Found while resolving [[bug-a-a-record-copy-does-not-retain-an-interface-field]], which fixed the same gap one level in (a plain record local/copy).

Shape

type TRec = record a: Integer; f: IFoo; end;
var arr: array[0..2] of TRec;   { or: array of TRec }
begin
  for i := 0 to 2 do arr[i].f := TFoo.Create('e');
end;   { FPC destroys 3 here. pxx destroys 0. }

Cause

Element kind 3 (record with managed fields) routes the element walk through PXXRecordRelease, and that helper owns kinds 1-3 by design: it runs with the codegen heap lock held on x86-64 --threadsafe, where releasing an interface would re-enter the non-reentrant spinlock. The interface half lives in PXXRecordReleaseIntf, which the scalar record paths now call BEFORE the lock — PXXArrayReleaseImmediate / PXXDynArrayReleaseDepth / PXXDynSetLen do not call it at all.

Note the array's own element retain on whole-array copy has the mirror hole.

Fix

Give the three element walks in compiler/builtin/builtinheap.pas a kind-3 arm that calls PXXRecordReleaseIntf (respectively PXXRecordRetainIntf) alongside PXXRecordRelease/Retain — the descriptor already carries the kind-4 members, so nothing new has to be emitted. The lock question is the one to answer first: the dynamic-array walks run under the codegen lock on x86-64 --threadsafe (which is why ManagedElemKindLocked refuses kind-4 ELEMENTS there), so the interface sub-pass has to be hoisted out of the locked region the same way, or refused under --threadsafe and left as today's leak.

Gate

make compiler/pascal26 (fixedpoint) + tools/gate.sh quick, plus a case in test/test_interface_containers.pas with FPC-matched destroyed counts, native and --threadsafe (must TERMINATE), and one cross target under qemu.

Resolution 2026-08-21 (Track A)

Fixed the same day it was filed, immediately after its scalar twin [[bug-a-a-record-copy-does-not-retain-an-interface-field]] — the machinery that ticket added (PXXRecordRetainIntf / PXXRecordReleaseIntf, kind-4 members in the record descriptor) is exactly what the element walks needed.

It was not only a leak. The ticket said "leaks the interfaces"; measured, a whole-static-array copy b := a also DANGLED — the element retain never saw the interface member, so nilling a destroyed the objects b still pointed at and the next read segfaulted. Same array-shaped twin the scalar case had.

Three runtime walks in compiler/builtin/builtinheap.pas gained the interface sub-pass beside their kind-3 arm: PXXDynArrayRetainImmediate (unguarded — AddRef frees nothing), PXXDynArrayReleaseDepth and PXXArrayReleaseImmediate (both {$ifndef PXX_TS_HARDLOCK}).

Plus two x86-64 codegen sites that would otherwise OVER-release. x86-64 does not use PXXDynSetLen; SetLength is inlined, and its survivor retain called EmitManagedRecordRetain (kinds 1-3) while the blanket release of the old block now walks interfaces too. Measured before fixing: a shrink from 4 to 2 destroyed all four and then dangled. Both arms — IR_SETLEN_DYN and the depth-1 specialId sibling — now emit PXXRecordRetainIntf first. This is the one part that was not a copy of the scalar fix, and it is the part a "just add the arm to the runtime" reading would have shipped broken.

Measured against FPC 3.2.2, test/test_interface_containers.pas extended with the four record-element shapes:

shape FPC pxx before pxx after
array[0..2] of TRec at scope exit 3 0 3
array of TRec at scope exit 3 0 3
SetLength(d,4)SetLength(d,2) 2 0 2
...total after SetLength(d,0) 4 0 4
b := a, then nil a, read b[0].f.Name cc SIGSEGV cc
destroyed after that copy's scope 2 2 (both dead early) 2

Identical output under qemu on aarch64 / arm32 / i386 / riscv32.

Residual under --threadsafe on x86-64, deliberate and asserted: the release sub-pass is compiled out (PXX_TS_HARDLOCK), because IR_SETLEN_DYN and IR_DYNUNIQUE hold the codegen spinlock across the walk and _Release re-enters it through FreeMem. The identical residual, for the identical reason, ManagedElemKindLocked already keeps for kind-4 ELEMENTS. The retain still runs, so it is a LEAK and never a dangle, and the Makefile asserts the program TERMINATES with those zero counts. Lifting it is [[feature-a-reentrant-heap-lock-and-per-thread-arenas]].

Gate: make compiler/pascal26 (fixedpoint) + tools/gate.sh quick GREEN.

Log

Resolution 2026-09-06 (frankH) — the residual, lifted by its own named condition

The 2026-08-21 resolution left the x86-64 --threadsafe arm compiled out and wrote down what would unblock it: "Lifting it is [[feature-a-reentrant-heap-lock-and-per-thread-arenas]]". The reentrant half of that landed today, so both {$ifndef PXX_TS_HARDLOCK} guards on PXXRecordReleaseIntf are removed — one in PXXDynArrayReleaseDepth, one in PXXArrayReleaseImmediate.

Why not the scalar path's answer. decide-interface-members-in-aggregates- lock-strategy chose to run the interface pass UNLOCKED, hoisted ahead of EmitAcquireHeapLock. That option does not exist here: this walk's callers are already inside the lock when they reach it, so there is nothing to hoist out of. Reentrancy is the only route, which is why the residual waited for it rather than copying the scalar fix.

Measured, both directions.

test_interface_containers before after
--threadsafe vs native 8 counts read 0 byte-identical
--threadsafe -dPXX_NO_REENTRANT_HEAPLOCK rc=212, heap-lock text, at the first dyn-array walk

The 212 is the discriminating control: message checked, not just the code.

The Makefile row was a control that encoded the defect, and it had already gone stale

The --threadsafe row asserted its OWN literal, eight counts of which were the leak. Lifting the kind-4 degradation earlier today moved dyn/after shrink/ shrink to 2/2/4 and left that row asserting 0/0/0 — red at HEAD for one commit, and I put it there. Both rows now share one literal, with a comment saying they must stay identical and why.