← board

A managed record function result runs neither Initialize nor Finalize

Repro

{$mode objfpc}{$H+}{$modeswitch advancedrecords}
type
  TFoo = record
    id: Integer; pad1, pad2, pad3: Int64;
    class operator Initialize(var a: TFoo);
    class operator Finalize(var a: TFoo);
  end;
class operator TFoo.Initialize(var a: TFoo); begin a.id := 0; WriteLn('  Init'); end;
class operator TFoo.Finalize(var a: TFoo);   begin WriteLn('  Fin    id=', a.id); end;

function Make: TFoo; begin Make.id := 7; end;
procedure TakeConst(const f: TFoo); begin WriteLn('  callee(const) id=', f.id); end;

begin
  TakeConst(Make);
end.
fpc 3.2.2 pxx at 188316478939 pinned
Init on entry to Make yes no no
callee(const) id= 7 7 7
Fin id=7 at end of statement yes no no

The pin agreeing with HEAD is the control that dates this: it predates the Copy/AddRef work entirely.

Why it matters — it is the constructor case

A record carrying Initialize/Finalize is carrying an invariant: a handle, a buffer, a refcount. The idiomatic way to hand one out is a factory function, and that is precisely the path where neither operator runs. The value the caller receives was never Initialized, so its invariant is whatever the stack held; and the temp holding it is never Finalized, so anything it owns leaks. Both halves are silent.

This is not the const/var parameter rule (fpc runs no operator there either, and pxx now agrees). It is the RESULT variable's own lifetime, which fpc treats like any other managed local.

Where to look

WrapManagementOpsRange (compiler/pasparser_proc.inc ~631) walks isTarget = skLocal/skGlobal only. A function's Result is neither of those in this compiler's symbol kinds — the same structural reason the by-value PARAMETER copy had no lifecycle at all until 2026-09-06, and it is worth checking whether one predicate can own both populations rather than growing a third (devdocs/dev/normalise-dont-special-case.md).

The caller-side half is separate: the temp that receives the result needs the same end-of-statement finalize queue the by-value argument temp now uses (IRFlushPostCallIntf, PostCallIntfSym/PostCallIntfRec).

Check before fixing: the by-value argument path must not finalize the same storage twice. TakeVal(Make) currently prints one Fin id=107 under both compilers; if the result temp gains a finalize and the argument copy keeps its own, that row must still print exactly one.

Not taken

Found while closing the AddRef half of feature-pascal-management-operators-copy-and-addref, as the residual that kept one fixture from being byte-identical to fpc. Parked rather than folded in: it is a different mechanism (result lifetime, not parameter lifetime) and it needs the double-finalize question above answered before it is safe.