← board

Measured

1000 trips, -dPXX_ALLOC_CENSUS, x86-64, on 42507851cdde. Same source, same binary path, the only difference is --threadsafe:

shape                                    default   --threadsafe
dyn array of Variant                          4      3848   LEAK
dyn array of COM interface                    4      3848   LEAK
dyn array of Variant + grow/shrink/regrow     4      3916   LEAK
---- unaffected, for contrast ----
dyn array of AnsiString                       4         4
dyn array of PromoInt                         9         9
a plain local Variant                         1         1

Roughly four leaked blocks per trip, i.e. one per element, which is the whole array.

Why it happens, and why the choice is right

ManagedElemKindLocked (symtab.inc ~2998) degrades element kinds 4 (COM interface) and 6 (Variant) to 0 when ThreadSafeMode is set, so the walk skips them. Its header explains the mechanism and the choice is not conservatism: releasing an interface element runs _Release -> Destroy -> FreeMem, and FreeMem re-acquires the same NON-REENTRANT spinlock the dynamic-array paths already hold, so the program would HANG rather than leak. Kind 5 (promo) is deliberately not refused, because a promo payload is only ever an AnsiString and its release is the same PXXStrDecRef kind 1 has always done under that lock — which the measurements above confirm: promo is the one managed kind that stays clean.

A hang is worse than a leak and this ticket does not dispute the trade.

What is missing is the price, and an owner for the residual

The header calls it "the pre-existing leak" and routes lifting it to decide-interface-members-in-aggregates-lock-strategy. That decision is decided, and feature-a-reentrant-heap-lock-and-per-thread-arenas is done — but done for the ALLOCATOR half only. Its own summary says "REENTRANCY STAYED PARKED AND UNNEEDED": the magazine fast path was built in the emitter precisely so nothing could re-enter the non-reentrant lock. So the lock is still non-reentrant, the degradation is still required, and the leak is still there — while both tickets that would lead a reader to it read as closed.

That is the gap this ticket exists to close: a deliberate leak whose two reference points are both marked done, with no open ticket naming it and no number attached to "benign".

It is not benign at the scale it applies to. Any --threadsafe program holding Variants or interfaces in dynamic arrays leaks the whole array on every release, forever. --threadsafe is what a long-running server build uses, which is the workload where an unbounded leak matters most.

Fixing it needs the reentrancy half, or a split unlocked pass

Both routes are already described in the decided ticket: a reentrant heap lock, or a separate interface/variant pass emitted OUTSIDE the lock — the latter being exactly what bug-a-class-managed-fields-not-finalized-on-destroy did for class fields (kind-4 pass first and unlocked), and what PXXRecordRetainIntf/ReleaseIntf already do for record members. The container case was left behind when those landed.

Note the shape of the existing solution before designing a new one: the unlocked pass is a proven pattern here, used twice, and this is the third site.

Measuring notes for whoever takes it

-dPXX_ALLOC_CENSUS and -dPXX_HEAP_DEBUG both disable the per-thread magazine (they instrument PXXAlloc and the magazine bypasses it), so census numbers describe the locked path — which is the path this bug is about, so the instrument and the defect agree here. Do not assume that holds for a throughput measurement.

STATIC arrays are NOT gated and do not leak: their scope-exit walk is emitted outside any lock. Only the dynamic-array paths hold it.

2026-09-06 — FIXED. The degradation is gone because the lock re-enters.

The owner ruled arm (a) of decide-a-how-should-the-nilpy-managed-finalize-re-enter-the-heap-lock, the heap lock gained owner+depth (feature-a-make-the-heap-lock-reentrant), and ManagedElemKindLocked's if ThreadSafeMode then kind := 0 for kinds 4 and 6 is deleted. Nothing about the trade this ticket declined to dispute has changed — _Release -> Destroy -> FreeMem still re-enters the same lock. The lock now survives it.

test/test_threadsafe_dynarray_releases_variant_and_interface_elements.pas, 1000 trips of each shape, -dPXX_ALLOC_CENSUS:

allocs frees live
pinned (pre-fix) 13891 5952 7939
HEAD 13891 13888 3
HEAD -dPXX_NO_REENTRANT_HEAPLOCK rc=212, the deadlock

Same allocation count on the two that finish, so this is the free side alone, which is what this ticket said it would be.

The third row is what makes the first two mean something. With reentrancy switched off in the same tree, this exact program hits the heap-lock deadlock diagnosis — so the row cannot pass for a reason unrelated to the fix, and the reentrancy is demonstrated load-bearing rather than assumed. Verified by the stderr TEXT and not only by the exit code: 212 arriving for another reason would pass the control and prove nothing.

It said: a reentrant heap lock, or a separate unlocked interface/variant pass — noting the unlocked pass was proven twice already (class fields, PXXRecordRetainIntf/ReleaseIntf) and this was the third site. The fork chose the lock. The standing objection to the unlocked-pass route is what decided it: every future release site must then remember to run outside the lock, which is per-site discipline that arm (a) exists to delete.

Not closed by this: the record COM-interface fields

builtinheap.pas still carries two {$ifndef PXX_TS_HARDLOCK} guards on PXXRecordReleaseIntf inside the dyn-array-of-records walks — the row named at the bottom of this ticket. Same shape, should fall the same way, deliberately left for a change that can measure it on its own rather than riding in on a commit whose control is about elements.

2026-09-06, later — the record row IS closed

The two {$ifndef PXX_TS_HARDLOCK} guards this ticket deliberately left standing are lifted, under [[bug-a-array-of-records-with-interface-fields-leaks-the-interfaces]], whose own resolution had named the reentrant heap lock as the unblocking condition. test_interface_containers --threadsafe is byte-identical to native; the same program with -dPXX_NO_REENTRANT_HEAPLOCK dies rc=212 with the heap-lock text.