fix(codegen): for-in over enumerators — promoted loop var and leaked enumerator

Two bugs in the enumerator-protocol for-in lowering, found via a user
program iterating a TList<Integer>:

- QBE: the mem2reg promotion scan did not treat the for-in loop
  variable as a store target, so an otherwise-unaddressed scalar loop
  var was promoted to an SSA temp while the lowering wrote the element
  with storew %t, %_var_<name> — invalid IR ('invalid type for second
  operand ... in storew').  The loop variable now always keeps a stack
  slot.

- Both backends: GetEnumerator's result (an owned +1 constructor
  return) was re-retained when stored into the synthetic enumerator
  slot, while the function epilogue releases the slot exactly once —
  leaking one enumerator per loop (rc=1 in every --debug leak report).
  The owned reference is now transferred into the slot.

e2e leak-check tests run the same list-summing loop under --debug on
both backends and assert clean output with no leak report.
This commit is contained in:
Graeme Geldenhuys 2026-06-12 15:05:43 +01:00
parent eabf20ae7f
commit 1dfc693fe5
4 changed files with 62 additions and 5 deletions

View file

@ -193,6 +193,11 @@ begin
end
else if AStmt is TForInStmt then
begin
{ The loop variable is written through its slot address by the
for-in lowering (storew/storel into %_var_<name>) — it must stay
a stack slot, never a promoted SSA temp. }
if ASet.IndexOf(TForInStmt(AStmt).VarName) < 0 then
ASet.Add(TForInStmt(AStmt).VarName);
CollectAddressTakenExpr(TForInStmt(AStmt).CollExpr, ASet);
CollectAddressTakenStmt(TForInStmt(AStmt).Body, ASet);
end

View file

@ -7975,10 +7975,11 @@ begin
else
Self.Emit(#9'callq ' + Sym);
{ ARC-assign the enumerator into the synthetic slot }
{ Move the enumerator into the synthetic slot. GetEnumerator's
result is an owned +1 (constructor/call result) — transfer it;
an extra AddRef here leaks one enumerator per loop because the
function epilogue releases the slot exactly once. }
Self.Emit(#9'pushq %rax');
Self.Emit(#9'movq %rax, %rdi');
Self.Emit(#9'callq _ClassAddRef');
Self.Emit(Format(#9'movq %s, %%rdi', [EnumOp]));
Self.Emit(#9'callq _ClassRelease');
Self.Emit(#9'popq %rax');

View file

@ -3247,10 +3247,12 @@ begin
else
EmitLine(Format(' %s =l call %s(l %s)', [EnumT, FuncName, SelfT]));
{ ARC-assign the enumerator into the synthetic slot }
{ Move the enumerator into the synthetic slot. GetEnumerator's
result is an owned +1 (constructor/call result) — transfer it; an
extra AddRef leaks one enumerator per loop because the function
epilogue releases the slot exactly once. }
OldT := AllocTemp();
EmitLine(Format(' %s =l loadl %s', [OldT, EnumSlot]));
EmitLine(Format(' call $_ClassAddRef(l %s)', [EnumT]));
EmitLine(Format(' call $_ClassRelease(l %s)', [OldT]));
EmitLine(Format(' storel %s, %s', [EnumT, EnumSlot]));

View file

@ -40,6 +40,11 @@ type
procedure TestDebug_ClassFieldFromCall_NoLeak;
{ Multiple same-named typed handlers share one slot — no over-release. }
procedure TestDebug_MultiHandlerVar_NoOverRelease;
{ for-in over a TList: GetEnumerator's +1 result must be transferred
into the loop slot, not re-retained — one enumerator leaked per
loop otherwise. }
procedure TestDebug_ForInEnumerator_NoLeak;
procedure TestDebug_ForInEnumerator_NoLeak_Native;
end;
implementation
@ -231,6 +236,50 @@ const
{ ------------------------------------------------------------------ }
const
SrcForInEnum = '''
program P;
uses generics.collections;
var
L: TList<Integer>;
I, Total: Integer;
begin
L := TList<Integer>.Create;
L.Add(5);
L.Add(4);
Total := 0;
for I in L do
Total := Total + I;
WriteLn(Total);
end.
''';
procedure TE2ELeakCheckTests.TestDebug_ForInEnumerator_NoLeak;
var
Output: string;
ExitCode: Integer;
begin
if not ToolchainAvailable() then begin Ignore('toolchain unavailable'); Exit end;
AssertTrue('compile+run',
CompileAndRunWithRTLDebug(SrcForInEnum, Output, ExitCode, True));
AssertEquals('exit 0', 0, ExitCode);
AssertEquals('stdout', '9' + LE, Output);
AssertTrue('no leak report, got: ' + Output, Pos('leak', Output) < 0);
end;
procedure TE2ELeakCheckTests.TestDebug_ForInEnumerator_NoLeak_Native;
var
Output: string;
ExitCode: Integer;
begin
if not ToolchainAvailable() then begin Ignore('toolchain unavailable'); Exit end;
AssertTrue('compile+run',
CompileAndRunWithRTLDebugOn(beNative, SrcForInEnum, Output, ExitCode, True));
AssertEquals('exit 0', 0, ExitCode);
AssertEquals('stdout', '9' + LE, Output);
AssertTrue('no leak report, got: ' + Output, Pos('leak', Output) < 0);
end;
procedure TE2ELeakCheckTests.TestDebug_NoLeak_NoReport;
var
Output: string;