← board

What

Extended syntax lets a function be called as a statement. The result is a fresh handle with a +1 count that nobody stores, and nothing ever released it.

for i := 1 to 1000 do MkS(i);      { live=979  }
for i := 1 to 1000 do MkArr(i);    { live=1968 }

FPC compiles the identical program and -gh reports 0 unfreed memory blocks.

A managed record result was already clean — it goes back through an sret buffer the caller allocates and finalizes. Only the register-returned kinds (string, dyn array) had no owner.

Three findings, and only the first is the one the title names

1. The helper already existed and was switched off. IRDropManagedStrResult did exactly this job, gated if not PyProgramMode then Exit, on the stated premise that "Pascal has no value-discarding expression statement". The NilPy arm of this bug was found and fixed (bug-nilpy-discarded-string-result-leaks); the Pascal sibling was left standing behind a mode gate. The premise is simply false. This is the normalise-dont-special-case failure mode in its purest form — the second path is the one that stays broken — and the guard against it ("fixed one arm of a double case? grep for the sibling") is what the mode gate defeated, because the sibling was grepped and did look handled.

2. Ungating it changed nothing, because the type tag was not the thing. A function called as a statement is lowered as a void call:

#8206 kind=8 tk=0            { AST: AN_CALL, tyUnknown }
23: call a=735 b=22 ival=1 tk=0

The return type is erased before the discard site, so every type-tag test reads tyUnknown and no managed check can fire. The proc table still knows (Procs[pi].RetType), and the AST node still carries the proc index. Asking the tag was correct about something else: it was correct about the lowered node, and the question was about the callee.

3. That still left the bare-statement bodies leaking. After (2), for .. do begin MkS(i); end; was clean at live=4 while for .. do MkS(i); was still at 979 — a loop body that is a bare statement reaches neither AN_BLOCK nor the AN_SEQ spine, which are the only two places the discard ran. This is the same coverage hole, one construct over, as the AN_IF-arm flush and the loop-body flush before it: the fix covered the arms it was written for, and reads as complete. The park now runs at all eight statement-body positions, the same set the flush was added to.

Measured

-dPXX_ALLOC_CENSUS, 1000 trips, last threshold. Pre-fix column is a rebuild with the change stashed (converged after 1 round(s)), not a recollection.

context pre post
for .. do MkS(i); (bare body) 979 4
for .. do MkArr(i); (dyn array) 1968 4
begin MkS(i); end 979 4
if b then / else arm 979 4
case value arm / else arm 979 5
while / repeat body 979 4
try body / finally body 979 4
except arm 924 6
with body 979 4

Twelve contexts, every one leaking before and bounded after. Program values are byte-identical pre and post on every row — the park must not consume the value it parks, and the test asserts a used result and a stored guard for exactly that direction.

Clean under -dPXX_HEAP_DEBUG, identical at -O0/-O2/-O3, and identical output and identical leak counts (allocs=8671 frees=8652 live=19) on x86-64, i386 and aarch64. FPC oracle agrees on the value line and reports 0 unfreed.

Tests

test/test_discarded_managed_result_leaks.pas, wired into test-core, test-i386 and test-aarch64. Each cross row builds its own x86-64 comparison binary rather than borrowing test-core's, so no comparison can run against a file the target never built.

The dyn-array arm is not decoration: its temp comes from a layout descriptor, so a wrong element width there is a target-specific double free rather than a leak.

Fix

IRDropManagedStrResultIRDropManagedResult(astNode, irNode): mode gate removed, return type read from the proc table when the node's tag is erased, dyn-array arm added, and called at all eight statement-body positions. The name lost its Str in the same edit — it parks arrays now, and an 80%-accurate name is the worse kind.

Fixed in commit 4af4645ba.