A Delphi or FPC function that returns a record does not get a fresh, zeroed Result on every call. That hidden Result variable starts out zero exactly once, and nothing re-zeroes it automatically between calls, so clearing it on entry is the function's own job. Do that clearing with FillChar(Result, SizeOf(Result), 0) and, from the second call onward, the routine overwrites a live string or dynamic-array reference instead of releasing it, orphaning whatever heap block that reference pointed to
The scenario where this bites is mundane. A batch process opens a stack of third-party PDFs and walks every annotation on every page, pulling the comment text into an audit log. Nothing about that loop looks dangerous: every call is a plain function returning a plain record, no pointers in sight, nothing that resembles manual memory management at all. Reference counting inside a record is a plain Object Pascal accounting rule, not a quirk specific to any one library, and any Delphi or FPC codebase that mixes FillChar with record types holding strings or dynamic arrays is exposed to the same defect
Why Does FillChar on a Record Result Leak Strings?
FillChar leaks strings because it has no idea what kind of data it is overwriting. FillChar(X, Count, Value) works on any variable at all: it takes an untyped block of Count bytes and stamps every one of them with Value, and that is the entire contract. This is exactly what makes FillChar fast and general-purpose, because it never inspects the type of X and never branches on what the underlying bytes mean. A UnicodeString or WideString field inside a record is not the characters themselves; it is a pointer to a heap block that carries a reference count ahead of the character data. FillChar sees a handful of bytes that happen to hold a pointer value and overwrites them with zero exactly as it would overwrite an Integer or a Double field. The pointer disappears, the reference count it should have decremented first is never touched, and the block it pointed to sits allocated with nothing left referencing it
How the Compiler Tracks Strings and Dynamic Arrays Inside a Record
Object Pascal calls a type managed when the compiler has to run extra code to keep it correct across assignment and scope exit. Long string types such as AnsiString, UnicodeString, and WideString qualify, and so do dynamic arrays, interfaces, and Variants, along with any record or fixed array that contains one of those as a field. For every managed field, the compiler quietly emits the bookkeeping that would otherwise be tedious and easy to get wrong by hand: increment a reference count on assignment, decrement it when the holding variable is overwritten or goes out of scope, and free the underlying block once that count reaches zero. That machinery is why ordinary Pascal code never manually allocates or releases a string, and why assigning one dynamic array to another is a cheap, safe operation rather than a manual copy loop. System.Default and Finalize are the two documented ways to invoke that same release logic on demand, and they are what a record's clearing code should call instead of a raw memory fill
type
TLineItem = record
Description: string; // managed: reference-counted
Quantity: Integer; // unmanaged: plain ordinal
end;
function GetLineItem(Index: Integer): TLineItem;
begin
FillChar(Result, SizeOf(Result), 0); // clears bytes, not the reference
Result.Quantity := Source[Index].Qty;
Result.Description := Source[Index].Text;
end;
var
Item: TLineItem;
I: Integer;
begin
for I := 0 to High(Source) do
begin
Item := GetLineItem(I); // second pass onward: leaks the prior Description
Log.Add(Item.Description);
end;
end;
Why Does the Leak Only Start on the Second Call?
The first call in a loop is always harmless, which is exactly what makes this defect easy to miss in testing. A local variable of a managed record type starts out zero, and nothing re-zeroes it automatically between one loop pass and the next, so the first time a loop assigns a function's return value into that variable, its Description or ContentsText field is still nil. FillChar overwrites nil with zero, which changes nothing as far as the reference count is concerned, and the call returns looking entirely correct. The second call is different: the same local variable already holds whatever the first call wrote into it, and the new call's Result is written directly into that same storage rather than into fresh, empty memory. FillChar at the top of that second call zeroes a field that is no longer nil, and everything downstream of that byte pattern is silently wrong from then on. A test that calls the function once and inspects the result will never see the problem; only a loop, or any code path that calls the function repeatedly against the same destination, exposes it
A Real Leak: Annotations, Bookmarks, and Link Records
PDFiumPas shipped exactly this defect before version 1.56.4, in three functions that each return a record holding at least one managed field: the page-level annotation reader returns a TPdfAnnotation carrying ContentsText and AuthorText strings, the bookmark reader returns a TBookmark carrying a Title string, and the link-annotation reader returns a TLinkAnnotation carrying an ActionPath string and a Points dynamic array. All three opened with the same shape shown below: clear Result with a raw FillChar, then fill in the fields one at a time from the underlying page data. Walking every annotation on a page one at a time, the ordinary way to build an audit list or a review panel, called the annotation reader in a loop and leaked the previous annotation's text on every pass after the first; a PDF crafted with an unusually large number of text-bearing annotations could grow a long-running process's memory for as long as that process kept running. The fix touched one line in each function: replacing FillChar(Result, SizeOf(Result), 0) with Result := Default(TPdfAnnotation) was enough, because assigning Default to a managed record runs the compiler's ordinary release-then-clear sequence instead of a raw memory fill
function GetPageAnnotation(Page: FPDF_PAGE; Index: Integer): TPdfAnnotation;
var
Annotation: FPDF_ANNOTATION;
ContentLength: LongWord;
begin
Annotation := FPDFPage_GetAnnot(Page, Index);
FillChar(Result, SizeOf(Result), 0); // clears bytes, not a live reference
Result.Subtype := DecodeAnnotationSubtype(FPDFAnnot_GetSubtype(Annotation));
ContentLength := FPDFAnnot_GetStringValue(Annotation,
FPDFANNOT_TEXTTYPE_Contents, nil, 0);
if ContentLength >= 4 then
begin
SetLength(Result.ContentsText, ContentLength div 2 - 1);
FPDFAnnot_GetStringValue(Annotation, FPDFANNOT_TEXTTYPE_Contents,
Pointer(Result.ContentsText), ContentLength);
end;
end;
The Same Hazard Behind a var Parameter
The bookmark reader shows a subtler version of the same problem, because the record that gets cleared with FillChar is not the function's own Result but a var parameter one call down. SetBookmarkData takes its output as var Data: TBookmark and used to clear Data at the top of its body with FillChar; GetBookmark, the public function that actually returns a TBookmark, calls SetBookmarkData and passes its own Result straight through as that var argument. A var parameter is passed by reference, so Data inside SetBookmarkData and Result inside GetBookmark are the same storage under two names, and whatever aliasing risk applies to a function's own Result applies just as directly to any helper routine that receives it by reference. Reviewing only the functions that literally declare a record return type misses this shape; the search has to follow every var and out parameter that a Result gets forwarded into as well
procedure TPdf.SetBookmarkData(Bookmark: FPDF_BOOKMARK; var Data: TBookmark);
var
BufferSize: LongWord;
begin
Data := Default(TBookmark); // fixed: was FillChar(Data, SizeOf(Data), 0)
Data.Handle := Bookmark;
if Bookmark <> nil then
begin
BufferSize := FPDFBookmark_GetTitle(Bookmark, nil, 0);
if BufferSize >= 4 then
begin
SetLength(Data.Title, BufferSize div 2 - 1);
FPDFBookmark_GetTitle(Bookmark, PWideChar(Data.Title), BufferSize);
end;
end;
end;
function TPdf.GetBookmark(const Title: WString): TBookmark;
begin
CheckActive;
SetBookmarkData(FPDFBookmark_Find(FDocument, PWideChar(Title)), Result);
end;
When Is FillChar Still the Right Call?
FillChar is still correct, and often slightly cheaper, for a record built entirely from ordinals, floating-point fields, fixed-size arrays of those, or other plain records made of the same, because there is nothing in it for the compiler to finalize. PDFiumPas's own rectangle type is exactly that case: TPdfRectangle holds four Double fields and nothing else, and clearing one with FillChar releases nothing because there is nothing reference-counted to release. The check that separates the two cases is simple to state: does any field of the record, at any nesting depth, have type string, AnsiString, WideString, a dynamic array, an interface, or a Variant? A record can look perfectly numeric at the top level and still fail that test if one of its fields is itself a record that buries a string a few layers down, so the check has to follow nested records all the way through rather than stopping at the outermost field list. Auditing an existing codebase for this pattern is mechanical rather than exhaustive: search for every FillChar call whose target is a record variable, then check that record's field list against the managed-type list above. PDFiumPas's own v1.56.4 audit ran exactly that search across the whole library and found this exposure in one unit; every other FillChar call site was already clearing a plain numeric record, where FillChar was, and remains, the right tool
The same compiler behavior that makes a reused Result dangerous here also drives a related family of Delphi-versus-FPC disagreements elsewhere in this codebase; a companion article on cross-compiler pitfalls covers a case where FPC and Delphi disagree about exactly when a record-result temporary is finalized inside a single expression, a different symptom of the same underlying fact that a function's record Result is not always the fresh, private storage it appears to be. The annotation loop used as the running example throughout this article is not hypothetical, either: it is the same page-by-page walk you would write while building an annotation review panel, which is exactly the code shape that turned a one-line FillChar into a slow memory leak in the first place
None of this requires switching libraries or chasing a bug in someone else's compiled code: it is a property of the Object Pascal language itself, one every Delphi and FPC developer works with daily, and the fix is a single function call once you know to look for it. The annotation, bookmark, and link-annotation APIs described here ship as part of the PDFium Component for Delphi, C++Builder, and Lazarus/FPC, alongside the rest of the PDF reading, rendering, and annotation surface covered elsewhere on this blog