← board

bug: function Result of a managed-field record, passed as a call arg in its own reassignment, segfaults

Symptom

A function whose return type is a record with a managed field (here a dynamic array; an AnsiString field is the same shape), where the function's Result is passed as an argument to a call whose value is assigned back to Result, segfaults. Routing through a local accumulator instead works.

program mr;
type TR = record d: array of Integer; end;
function Make(n: Integer): TR;
begin SetLength(Result.d, 1); Result.d[0] := n; end;
function Add(const a, b: TR): TR;
begin SetLength(Result.d, 1); Result.d[0] := a.d[0] + b.d[0]; end;

function ViaResult: TR;        { Result is a call arg in its own reassignment }
begin
  Result := Make(10);
  Result := Add(Result, Make(5));    { SEGFAULT }
end;

function ViaLocal: TR;         { same logic via a local }
var acc: TR;
begin
  acc := Make(10);
  acc := Add(acc, Make(5));
  Result := acc;                      { OK -> 15 }
end;

ViaLocal prints 15; ViaResult cores (SIGSEGV), x86-64 / stable v68.

Likely cause

Add(Result, …) is lowered with the callee's hidden return-slot pointer aliasing Result (the same storage is both an input arg and the destination). When the callee builds its managed result and the caller then assigns it onto Result, the old Result.d is released/overwritten while still referenced as the const a argument — a use-after-free / double-manage of the dynamic-array (or string) field. A plain (unmanaged) record, or a non-Result destination, dodges it.

Compare against the value-record / var-record arg paths (those are fine) and the managed-string-field family ([[bug-setlength-record-field-via-var-param]] fixed v67, [[bug-managed-length-via-pointer-deref]]) — this is the return-slot/self-arg corner of the same managed-aggregate area.

Same bug, already worked around elsewhere

This is the bug lib/rtl/bignum.pas + examples/bignum/bigmath.pas comments call bug-nested-managed-return-call-arg (which has no ticket file — this ticket is its home). Their standing workaround: never pass a managed-return call straight as an argument to another call — bind it to a temp first, e.g.

t := BigMulSmall(rem, BIG_BASE);   { not BigAdd(BigMulSmall(rem,BIG_BASE), …) }
d := BigFromInt(a.limbs[i]);
rem := BigAdd(t, d);

So the failure isn't only the literal Result := F(Result,…) self-aliasing form above — any managed-record-returning call used directly as an argument to another call can corrupt state. The self-arg / self-reassignment case (the repro) is the loudest (immediate segfault). Once fixed, the temp-binding in bignum.pas (BigFromStr, BigDivMod) and bigmath.pas can be simplified.

Acceptance

Diagnosis (2026-06-25) — partly improved, residual is flaky heap corruption

Re-tested on the current compiler (post pin v72). The loudest forms from the repro now PASS in isolation:

So the intervening managed-string work (v67 SetLength-on-field, v71 Length-via-deref) closed the simple cases. The bug is NOT gone, but it is now flaky / heap-layout-sensitive, not a deterministic self-arg segfault:

That signature = a heap/ARC corruption (double-free or release of a still-referenced handle) in the hidden return-slot lifetime, latent until a specific malloc/free sequence reuses the corrupted block. The fix is the one the acceptance already names — materialise a managed-record call result into a temp before it can alias/overwrite a slot that is also a live input — but it must be applied to the general hidden-result-pointer path, not just the literal Result := F(Result,…) self-arg, and verified against the mixed-allocation repro (deterministic crash) plus a cross run. Substantial ARC work; Track B's "bind managed-return calls to a temp first" workaround (bignum.pas, bigmath.pas) stands until then.

Minimal deterministic repro to drive the fix (segfaults): the b3b shape — a record d: AnsiString self-arg accumulator function called first, then a record d: array of Integer function whose body is Result := Add(Make(3), Add(Make(4), Make(5))), both printed in main.

Resolution (2026-06-26, Track A — commit 65d7fb56, pin v75)

Root cause was NOT the hidden return-slot aliasing the ticket guessed. The hidden result dest is a fresh scratch; the assignment uses ARC-correct IR_COPY_REC_MANAGED. The real bug: passing a managed-field record by value as a call arg materializes it into a temp (ir.inc IRLowerCallArg needTemp path), filled with IR_COPY_REC_MANAGED — which RELEASES the dest's old managed fields before copying. That temp is allocated during IR lowering, AFTER the prologue zero-init pass, so its fields held stack garbage → the release freed a bogus handle (double-free / heap corruption). Surfaced only when one arg is a managed-record-returning call (Add(Make(1), Result) / Add(Result, Make(5))); a plain lvalue arg (Add(Result, localVar)) needs no temp and was fine — which is why Result-as-arg looked like the trigger but was a red herring.

Fix: mark the temp SymIsHiddenArgTemp when the record has managed fields, so codegen nil-inits it before the body (mirrors the hidden aggregate-result temp in IRAppendCall). The release then no-ops on nil.

Verified: dyn-array-field and AnsiString-field repros, looped, valgrind-clean; make test green incl cross; self-host byte-identical; pinned v75. Track B can drop the bind-to-temp workaround in bignum.pas / chacha20poly1305.pas on rebuild.