← board

Status

The Variant half is fixed in b2997a31b (RecordDescMember + the RECORD tyVariant -> 5 arm, with PXXRecordRetain's missing kind-5 arm in the same commit): live=11658 -> 6, regression test test_record_variant_member_leaks.

The PromoInt half is fixed in f806993c8 as member kind 7, in both descriptor chains, RecordDescMember and ClassFieldNeedsFinal, with retain/release/zero arms in builtinheap. Regression test test_record_promo_member_leaks. Measured, live blocks over 2000 trips:

local record {promo, string}   1904 -> 6      class field    1787 -> 7
dyn array of that record       7685 -> 21     array[0..7]    7578 -> 18

NOTHING REMAINS OPEN. All four shapes are fixed — see "Closed out" at the end for the final tally. The section below is the history of the last one.

Formerly open:

Not covered by the promo fix, deliberately

An array[0..N] of PromoInt MEMBER. Both new arms are scalar-only (... and not UFldIsArray[fi]), matching the existing variant arm exactly rather than widening two mechanisms in one change. A fixed array OF records that each hold a scalar promo IS covered and is measured above (7578 -> 18); the uncovered shape is the array being the promo itself.

Kind 7 is the only member kind carrying a stride

Its typeRef word holds TypeSlotSize, because PromoInt is not one type: it resolves through PromoIntDefaultKind to tyPromoInt64 (16 bytes) on a 64-bit target and tyPromoInt32 (8) on a 32-bit one, and the numbered spellings are REFUSED on the target they do not match. A runtime constant would be right on half the fleet. Verified by reading emitted descriptor bytes on five targets (16 on x86-64/aarch64, 8 on i386/arm32/riscv32), with a promo-free program as the scanner's negative control.

The retain arm was the half that could have shipped broken

Describing the member makes PXXRecordRelease release it; a release without a matching retain does not leak, it DESTROYS SetLength survivors — the pairing failure that made 9cb079528 segfault master (reverted as a584e8fef). Proven rather than assumed: with only the retain arm removed and the compiler rebuilt, the new test reports 3/6000 and the -dPXX_HEAP_DEBUG build exits 139.

Measured first — the two bugs have different shapes

2000 trips (dyn-array rows 1000 x 8), -O2 -dPXX_ALLOC_CENSUS, live blocks:

shape                                        Variant field   PromoInt field
local record, field is the ONLY member       clean            clean (6)
local record, + an AnsiString member         clean (2)        LEAK (1904)
record inside a dyn array                    LEAK (7822)      LEAK (7685)
class field, + an AnsiString member          clean (1)        LEAK (1787)

Controls: the string-only record and string-only class are clean (2 and 1), and a promo field assigned an INLINE value is clean — only the HEAP tier owns an AnsiString payload, so that row identifies the leaked block as the promo payload rather than the record or the string.

PromoInt leaks in every shape that has a second managed member. There is no promo member kind anywhere: the record walk knows 1 String, 2 DynArray, 3 Record, 4 interface, 5 Variant, 6 NilPy binding, and nothing for a promo slot.

Variant leaks only through the RECORD descriptor. The local record and the class are both clean, because a class layout descriptor's chain HAS the arm:

rtti_emit.inc, CLASS layout (~1510)      RECORD layout (~1371)
  dyn array -> 2                           dyn array -> 2
  tyAnsiString -> 1                        tyAnsiString -> 1
  COM interface -> 4                       COM interface -> 4
  tyVariant -> 5            <-- MISSING    (none)
  tyClass -> 6              <-- MISSING    (none)
  else -> 3                                else -> 3

Two writers for one question, and they disagree. A Variant record member falls to else -> 3 and is walked as a NESTED RECORD with a typeRef that is not one.

FieldIsManaged (rtti_emit.inc:21) has the matching hole and gates both, via RecordDescMember: it recognises AnsiString, dyn arrays and nested records-with-managed-fields, and neither Variant nor promo. So for the record path the member is not even emitted.

A field of either kind ALONE measures clean, which is why this hid: with no recognised managed member the record never becomes managed and something else reclaims it. It takes a second, recognised member to expose the gap — the obvious one-field probe reports success.

Doing it — and the two traps in the obvious version

Do NOT extend FieldIsManaged. That is where I first pointed this ticket and it is wrong: ClassFieldNeedsFinal (rtti_emit.inc:56) already carries the Variant arm and says why it lives THERE and not in the shared predicate — "extending FieldIsManaged itself would flip records with variant fields into the managed-record codegen paths, a much broader change than destroy-time finalize." The class/record divergence is deliberate and documented, not an oversight.

The narrow fix mirrors that precedent one level down: extend RecordDescMember (the descriptor-member predicate) the same way ClassFieldNeedsFinal extends its own, and add the tyVariant -> 5 arm to the RECORD mKind chain, copying the CLASS chain's ordering so the array-ness test stays first.

TRAP: PXXRecordRetain HAS NO KIND-5 ARM. It dispatches kinds 1, 2 and 3 only, and computes memberSize := SizeOf(Pointer) for everything that is not a kind 3 — no 16-byte branch. PXXRecordRelease DOES clear kind 5 via PXXVarClear. So emitting kind-5 record members without adding the retain arm turns this leak into a DOUBLE FREE on every record COPY, exactly as widening the dyn-array descriptor stride did in 9cb079528 (reverted by a584e8fef) for the element walks. Add, in the same change:

else if kind = 5 then memberSize := 16      { in PXXRecordRetain }
5: PXXVarRetain(itemAddr);                  { its case arm }

A class's Variant field is unaffected by that gap today because a class is finalized, never copied by value — which is why the asymmetry has been survivable so far and will stop being survivable the moment records use kind 5.

Scope the narrow fix honestly. It reaches a record that is ALREADY managed, i.e. has a string/array/nested-managed member alongside the Variant. Measured, dyn array of records, 1000 trips x 8:

record v: Variant; s: AnsiString   live=7357   <- narrow fix reaches this
record v: Variant                  live=7708   <- it does NOT

The second stays broken because RecordHasManagedFields does not count a Variant, so the record is not managed at all, ManagedElemKind answers 0 for the element, and no walk is emitted to fix. Closing that one IS the broad change the ClassFieldNeedsFinal comment warns about, and it wants its own ticket and a full tier rather than being smuggled in here.

PromoInt needs a new member kind (7), memberSize := 16, with a decref arm and its retain mirror — and the same trap applies, both halves in one change. PXXPromoRetainOne (builtinheap.pas) exists for the retain half.

Gate: full tier. Widening what becomes a record descriptor member changes record copy and release behaviour corpus-wide, and gate.sh quick was GREEN on the commit that segfaulted test_promoint_array_cleanup this morning.

Why this keeps happening — read this part

Fourth spelling of one policy found in a single day, each missing a different subset: ManagedElemKind (canonical, and it DOES know kinds 5 and 6), the element walks, the x86-64 inline SetLength retain chains (bug-a-x86-64-inline-setlength-never-retains-promo-or-variant-elements), and now FieldIsManaged plus two divergent mKind chains.

The tempting conclusion is "make FieldIsManaged delegate to ManagedElemKind and collapse the two mKind chains into one". That is the normalise-dont-special-case shape and it deletes cases rather than adding them — but read ClassFieldNeedsFinal's comment first: one of these predicates feeds DESCRIPTOR EMISSION and the other feeds CODEGEN PATH SELECTION, and they are deliberately not the same question. Collapsing them is a real design change with a real blast radius, not a tidy-up. It may still be right; it is not free, and it is not this ticket.

Closed out (2026-09-01)

Four commits, each a different predicate, all of them saying "this field is not worth walking" for a different reason:

b2997a31b  RECORD descriptor never described a Variant member (kind 5)
f806993c8  a promo member had NO member kind at all (kind 7, + stride)
a544cab70  RecordHasManagedFieldsDepth2 counted neither, so a record whose
           managed members were ALL of those kinds was never WALKED
2859efe06  the membership arms excluded FIXED ARRAYS of either

Final tally, live blocks per 1000 trips:

local record {promo, string}                      1904 ->  6
class field {promo, string}                       1787 ->  7
dyn array of record with a promo member           7685 -> 21
record{v:Variant} in a dyn array                  7708 ->  4
record{p:PromoInt} in a dyn array                 7791 ->  7
record{v:Variant} nested in a managed record       936 ->  1
array[0..3] of Variant as a member                3799 ->  3
array[0..3] of PromoInt as a member               3860 ->  8
array[0..3] of Variant, in a dyn array           11398 -> 10
array[0..3] of PromoInt, in a dyn array          11110 -> 10

What this family was actually about

Four predicates, one concept, disagreeing. FieldIsManaged, RecordDescMember, ClassFieldNeedsFinal and RecordHasManagedFieldsDepth2 all answer some version of "does this field need managing", and each was written for its own caller. A field could satisfy one and fail another, and every leak here is a field that fell into a gap between two of them. Two of the fixes point in OPPOSITE directions — a544cab70 because a record was not managed enough for anything to walk it, 2859efe06 because a record became managed and thereby LOST the direct field-by-field scope-exit finalization it had been getting. That is the shape root-cause-over-microfix.md describes as a design flaw at three mechanisms; there are four.

Not collapsed here, deliberately: they are genuinely different questions (who is described, who is walked, which codegen path, what the copy does), and the ClassFieldNeedsFinal header explains why widening FieldIsManaged in particular is a much broader change. But anyone touching one of them should assume the other three disagree until measured.

Every shape in this family had a probe that reported success

Worth keeping, because it is why the family took four passes rather than one:

In every pair the obvious minimal probe is the clean one. A one-field test program reports success for all four bugs.

Regression tests

test_record_variant_member_leaks, test_record_promo_member_leaks, test_managed_record_gate_leaks, test_managed_member_array_leaks — all wired with expect_same + assert_no_leak at bound 50. Each was negative-controlled: with its own fix reverted the leak row measures 7357 / 1904 / 4988 / 18967 respectively, so none of them is a test that cannot fail. The correctness rows pass either way in every case, so the leak count is the load-bearing assertion — except for the retain halves, where reverting only the retain arm destroys survivors (3/6000) and segfaults under -dPXX_HEAP_DEBUG.

Log