← board

An interface as-cast retains on every execution and releases once per scope

Found by frankb-78 while attempting the target of umbrella-managed-memory-is-correct — ten common managed-memory constructs looped 2000 and 8000 times under -dPXX_ALLOC_CENSUS, slope as the measurement. Nine were flat. This one was not.

The measurement

IRMaterializeIntfCast builds the interface value into a hidden temp and calls PXXIntfAddRef, because a QueryInterface returns an owning reference; the temp is released at the enclosing routine's scope exit (or at end of main for a main-body temp). Both halves are deliberate and both are documented in the code.

What neither accounts for is that the temp is memoised in ASTLiftedVar — one slot per cast SITE, not per execution. A site inside a loop stores a different instance into that one word every trip, without releasing what was there. So the retain runs N times and the release once.

The leak is the boring half. The destructor not running is the bug, and no output assertion or leak bound catches that on its own — the objects are reachable-and-never-freed. Against the FPC oracle, identical source, a destructor counting its own calls:

row FPC pxx before pxx after
5 casts in the main body 4 0 4
3 casts inside a procedure 3 1 3
5 objects, no cast at all 5 5 5

Census slope agrees: 0.973 blocks per iteration in the main body, 1.871 per call for three casts in a procedure (three retains, one release — two lost), flat after.

Row three is the control that makes the other two readable: the implicit class→interface coercion (q := TThing.Create) goes down the ordinary managed-assignment path, which already releases the slot's previous value. It was correct all along, and without it 5 5 5 and 0 1 5 are equally consistent with a destructor that never increments.

Main-body 4-of-5 is not an off-by-one. The cast temp legitimately still holds the fifth object when the count prints and is released at end of main; FPC answers 4 for the same reason.

The fix

Release the temp's previous value immediately before the store that overwrites it, inside the same COM condition as the AddRef — a non-COM interface never retained here and must not release. Safe on the first execution because the slot starts nil (BSS for a main-body temp, prologue-zeroed for a local — the same guarantee EmitManagedLocalCleanup already relies on when it releases these temps at scope exit) and PXXIntfRelease is nil-safe.

Guard: test/test_interface_as_cast_temp_released_per_execution.pas, wired for x86-64. Positive control: pin v403 prints 0 / 1 / 5.

Not the same bug as feature-a-getinterface-refcounting

That one is a missing retain — __pxxGetInterface stores a borrowed pointer into a managed slot, so Supports is one release against zero retains, and its failure mode is an EARLY FREE. This one is a retain firing too often, and its failure mode is a destructor that never runs. Opposite signs, opposite symptoms, and folding them would make one ticket whose fix is two fixes. They do share a subsystem, so whoever takes the other one should read this first.

Cross targets — measured, not argued

The argument for skipping this was good: the lowering is shared (ir.inc) and the emitted call is an ordinary PXXIntfRelease every backend already emits for scope-exit cleanup, so there is no target-specific arm. The argument is also exactly the shape that has been wrong here before, and running it cost a minute.

i386, aarch64, arm32, riscv32 and xtensa all build the guard and produce output IDENTICAL to the x86-64 oracle — 4 / 3 / 5 on every one.

Log