fix(arc): release string call/getter transient passed to Write/WriteLn

A string-returning function, method or property getter used DIRECTLY as a
Write/WriteLn argument — WriteLn(GetBar) — returns a fresh +1 string that
_SysWriteStr only borrows.  EmitWrite emitted the write call but never released
that transient, leaking one string per call on BOTH backends.  (A normal
procedure's const-string parameter already released it; only the Write/WriteLn
built-in path was missing the release.  Assigning the result to a variable
first never leaked.)

EmitWrite now releases the string argument after the write when the argument
expression OWNS its reference (ExprOwnsRef / NativeExprOwnsRef) — a call or
concat result.  Plain variables, literals and PChars are borrowed and are not
released, so no double-free.  Native stashes the pointer across the
_SysWriteStr call and releases it after.

E2E regression (both backends, --debug leak tracker):
TE2ELeakCheckTests.TestDebug_WriteLnCallArg_NoLeak.
This commit is contained in:
Graeme Geldenhuys 2026-06-28 00:47:53 +01:00
parent a4d178b52c
commit 43a0f9e4eb
3 changed files with 82 additions and 4 deletions

View file

@ -9685,9 +9685,27 @@ begin
if K in [tyString, tyPChar] then
begin
Self.EmitExprToEax(ArgExpr);
Self.Emit(#9'movq %rax, %rsi');
Self.Emit(Format(#9'movl %s, %%edi', [FdLit]));
Self.Emit(#9'callq _SysWriteStr');
{ A string argument that OWNS its reference (a call/concat result that
returned a fresh +1 string) is borrowed by _SysWriteStr and nothing
else holds it release the transient after the write, or it leaks
once per Write/WriteLn. Stash the pointer across the call, then
release. Plain variables / literals / PChars are borrowed and are
not released. }
if (K = tyString) and NativeExprOwnsRef(ArgExpr) then
begin
Self.Emit(#9'pushq %rax');
Self.Emit(#9'movq %rax, %rsi');
Self.Emit(Format(#9'movl %s, %%edi', [FdLit]));
Self.Emit(#9'callq _SysWriteStr');
Self.Emit(#9'popq %rdi');
Self.Emit(#9'callq _StringRelease');
end
else
begin
Self.Emit(#9'movq %rax, %rsi');
Self.Emit(Format(#9'movl %s, %%edi', [FdLit]));
Self.Emit(#9'callq _SysWriteStr');
end;
end
else if K = tyDouble then
begin

View file

@ -9551,7 +9551,16 @@ begin
IsString := (ArgExpr.ResolvedType <> nil) and ArgExpr.ResolvedType.IsString();
ArgTemp := EmitExpr(ArgExpr);
if IsString then
EmitLine(Format(' call $_SysWriteStr(w %s, l %s)', [FdLit, ArgTemp]))
begin
EmitLine(Format(' call $_SysWriteStr(w %s, l %s)', [FdLit, ArgTemp]));
{ A string argument that OWNS its reference (a call/concat result that
returned a fresh +1 string) is borrowed by _SysWriteStr and nothing
else holds it release the transient here, or it leaks once per
Write/WriteLn. Plain variables / literals are borrowed (ExprOwnsRef
false) and must not be released. }
if ExprOwnsRef(ArgExpr) then
EmitLine(Format(' call $_StringRelease(l %s)', [ArgTemp]));
end
else if (ArgExpr.ResolvedType <> nil) and
(ArgExpr.ResolvedType.Kind = tyBoolean) then
EmitLine(Format(' call $_SysWriteBool(w %s, w %s)', [FdLit, ArgTemp]))

View file

@ -68,6 +68,11 @@ type
locals entirely, so every stored element leaked on BOTH backends. The
fix releases each element at scope exit. }
procedure TestDebug_StaticArrayOfInterface_NoLeak;
{ A string-returning call/getter used DIRECTLY as a Write/WriteLn argument
(WriteLn(GetBar)) returns a fresh +1 string that _SysWriteStr only borrows.
EmitWrite previously never released it, leaking one string per call. The
fix releases the owned string transient after the write (both backends). }
procedure TestDebug_WriteLnCallArg_NoLeak;
end;
implementation
@ -752,6 +757,34 @@ const
end.
''';
{ A string-returning getter used directly as a WriteLn argument. The getter
returns a fresh +1 string (Copy result); WriteLn only borrows it, so the
caller must release it after the write. Three calls => three leaks before
the fix. }
SrcWriteLnCallArg = '''
program P;
type
TFoo = class
private
function GetBar: String;
public
property Bar: String read GetBar;
end;
function TFoo.GetBar: String;
begin
Result := Copy('abcd', 1, 3);
end;
var
X: TFoo;
begin
X := TFoo.Create;
WriteLn(X.Bar);
WriteLn(X.Bar);
WriteLn(X.Bar);
X := nil;
end.
''';
procedure TE2ELeakCheckTests.TestDebug_ReceiverFieldAccess_NoLeak;
var
Output: string;
@ -788,6 +821,24 @@ begin
AssertTrue('no leak report (native), got: ' + Output, Pos('leak', Output) < 0);
end;
procedure TE2ELeakCheckTests.TestDebug_WriteLnCallArg_NoLeak;
var
Output: string;
ExitCode: Integer;
begin
if not ToolchainAvailable() then begin Ignore('toolchain unavailable'); Exit end;
AssertTrue('compile+run (qbe)',
CompileAndRunWithRTLDebugOn(beQBE, SrcWriteLnCallArg, Output, ExitCode, True));
AssertEquals('exit 0 (qbe)', 0, ExitCode);
AssertEquals('stdout (qbe)', 'bcd' + LE + 'bcd' + LE + 'bcd' + LE, Output);
AssertTrue('no leak report (qbe), got: ' + Output, Pos('leak', Output) < 0);
AssertTrue('compile+run (native)',
CompileAndRunWithRTLDebugOn(beNative, SrcWriteLnCallArg, Output, ExitCode, True));
AssertEquals('exit 0 (native)', 0, ExitCode);
AssertEquals('stdout (native)', 'bcd' + LE + 'bcd' + LE + 'bcd' + LE, Output);
AssertTrue('no leak report (native), got: ' + Output, Pos('leak', Output) < 0);
end;
initialization
RegisterTest(TE2ELeakCheckTests);