Eine Delphi- oder FPC-Funktion, die einen Record zurückgibt, bekommt nicht bei jedem Aufruf ein frisches, genulltes Result. Diese versteckte Result-Variable startet genau einmal bei null, und nichts nullt sie zwischen Aufrufen automatisch neu, sodass das Löschen bei Eintritt Aufgabe der Funktion selbst ist. Erledigt man dieses Löschen mit FillChar(Result, SizeOf(Result), 0), überschreibt die Routine ab dem zweiten Aufruf eine lebende String- oder dynamische-Array-Referenz, statt sie freizugeben, und macht den Heap-Block, auf den diese Referenz zeigte, herrenlos
Das Szenario, in dem das beißt, ist alltäglich. Ein Batch-Prozess öffnet einen Stapel Drittanbieter-PDFs und durchläuft jede Annotation auf jeder Seite, zieht den Kommentartext in ein Audit-Log. Nichts an dieser Schleife sieht gefährlich aus: Jeder Aufruf ist eine schlichte Funktion, die einen schlichten Record zurückgibt, keine Zeiger in Sicht, nichts, das nach manueller Speicherverwaltung aussieht. Referenzzählung innerhalb eines Records ist eine schlichte Object-Pascal-Buchhaltungsregel, keine Eigenart einer bestimmten Bibliothek, und jede Delphi- oder FPC-Codebasis, die FillChar mit Record-Typen mischt, die Strings oder dynamische Arrays halten, ist demselben Defekt ausgesetzt
Warum lässt FillChar auf einem Record-Ergebnis Strings auslaufen?
FillChar lässt Strings auslaufen, weil es keine Ahnung hat, welche Art von Daten es überschreibt. FillChar(X, Count, Value) funktioniert auf jeder beliebigen Variable: Es nimmt einen untypisierten Block von Count Bytes und stempelt jedes davon mit Value, und das ist der gesamte Vertrag. Genau das macht FillChar schnell und universell einsetzbar, weil es nie den Typ von X inspiziert und nie danach verzweigt, was die zugrunde liegenden Bytes bedeuten. Ein UnicodeString- oder WideString-Feld innerhalb eines Records ist nicht die Zeichen selbst; es ist ein Zeiger auf einen Heap-Block, der einen Referenzzähler vor den Zeichendaten trägt. FillChar sieht eine Handvoll Bytes, die zufällig einen Zeigerwert halten, und überschreibt sie mit null, genau so, wie es ein Integer- oder Double-Feld überschreiben würde. Der Zeiger verschwindet, der Referenzzähler, den es zuerst hätte dekrementieren sollen, wird nie angefasst, und der Block, auf den er zeigte, bleibt alloziert, ohne dass noch etwas darauf verweist
Wie der Compiler Strings und dynamische Arrays innerhalb eines Records verfolgt
Object Pascal nennt einen Typ verwaltet, wenn der Compiler zusätzlichen Code ausführen muss, um ihn über Zuweisung und Gültigkeitsbereichs-Ende hinweg korrekt zu halten. Lange String-Typen wie AnsiString, UnicodeString und WideString qualifizieren sich, ebenso dynamische Arrays, Interfaces und Variants, zusammen mit jedem Record oder festen Array, der eines davon als Feld enthält. Für jedes verwaltete Feld gibt der Compiler still die Buchhaltung aus, die ansonsten mühsam und von Hand leicht falsch zu machen wäre: einen Referenzzähler bei Zuweisung erhöhen, ihn dekrementieren, wenn die haltende Variable überschrieben wird oder den Gültigkeitsbereich verlässt, und den zugrunde liegenden Block freigeben, sobald dieser Zähler null erreicht. Diese Maschinerie ist der Grund, weshalb gewöhnlicher Pascal-Code nie manuell einen string alloziert oder freigibt, und weshalb das Zuweisen eines dynamischen Arrays an ein anderes eine billige, sichere Operation ist statt einer manuellen Kopierschleife. System.Default und Finalize sind die zwei dokumentierten Wege, diese gleiche Freigabelogik bei Bedarf aufzurufen, und genau die sollte der Löschcode eines Records aufrufen, statt eines rohen Speicher-Füllens
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;
Warum beginnt das Auslaufen erst beim zweiten Aufruf?
Der erste Aufruf in einer Schleife ist immer harmlos, was genau diesen Defekt beim Testen leicht übersehbar macht. Eine lokale Variable eines verwalteten Record-Typs startet bei null, und nichts nullt sie zwischen einem Schleifendurchlauf und dem nächsten automatisch neu, sodass beim ersten Mal, dass eine Schleife den Rückgabewert einer Funktion dieser Variable zuweist, deren Feld Description oder ContentsText noch nil ist. FillChar überschreibt nil mit null, was hinsichtlich des Referenzzählers nichts ändert, und der Aufruf kehrt vollkommen korrekt aussehend zurück. Der zweite Aufruf ist anders: Dieselbe lokale Variable hält bereits, was der erste Aufruf hineingeschrieben hat, und das Result des neuen Aufrufs wird direkt in denselben Speicher geschrieben statt in frischen, leeren. FillChar am Anfang dieses zweiten Aufrufs nullt ein Feld, das nicht mehr nil ist, und alles stromabwärts dieses Byte-Musters ist von da an still falsch. Ein Test, der die Funktion einmal aufruft und das Ergebnis inspiziert, wird das Problem nie sehen; nur eine Schleife, oder jeder Codepfad, der die Funktion wiederholt gegen dasselbe Ziel aufruft, legt es offen
Ein echtes Leck: Annotationen, Lesezeichen und Link-Records
PDFiumPas lieferte genau diesen Defekt vor Version 1.56.4 aus, in drei Funktionen, die jeweils einen Record zurückgeben, der mindestens ein verwaltetes Feld hält: Der Seiten-Annotations-Reader gibt ein TPdfAnnotation zurück, das die Strings ContentsText und AuthorText trägt, der Lesezeichen-Reader gibt ein TBookmark zurück, das einen Title-String trägt, und der Link-Annotations-Reader gibt ein TLinkAnnotation zurück, das einen ActionPath-String und ein Points-dynamisches-Array trägt. Alle drei begannen mit derselben unten gezeigten Form: Result mit einem rohen FillChar löschen, dann die Felder eines nach dem anderen aus den zugrunde liegenden Seitendaten füllen. Jede Annotation auf einer Seite eine nach der anderen zu durchlaufen, der gewöhnliche Weg, eine Audit-Liste oder ein Review-Panel zu bauen, rief den Annotations-Reader in einer Schleife auf und ließ bei jedem Durchlauf nach dem ersten den Text der vorherigen Annotation auslaufen; ein mit ungewöhnlich vielen textbehafteten Annotationen präpariertes PDF konnte den Speicher eines lange laufenden Prozesses so lange wachsen lassen, wie dieser Prozess weiterlief. Die Lösung berührte eine Zeile in jeder Funktion: FillChar(Result, SizeOf(Result), 0) durch Result := Default(TPdfAnnotation) zu ersetzen genügte, weil die Zuweisung von Default an einen verwalteten Record die gewöhnliche Freigabe-dann-Lösch-Sequenz des Compilers ausführt statt eines rohen Speicher-Füllens
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;
Dieselbe Gefahr hinter einem var-Parameter
Der Lesezeichen-Reader zeigt eine subtilere Version desselben Problems, weil der mit FillChar gelöschte Record nicht das eigene Result der Funktion ist, sondern ein var-Parameter einen Aufruf tiefer. SetBookmarkData nimmt seine Ausgabe als var Data: TBookmark entgegen und löschte früher Data am Anfang seines Rumpfs mit FillChar; GetBookmark, die öffentliche Funktion, die tatsächlich ein TBookmark zurückgibt, ruft SetBookmarkData auf und übergibt ihr eigenes Result direkt als dieses var-Argument durch. Ein var-Parameter wird per Referenz übergeben, sodass Data innerhalb von SetBookmarkData und Result innerhalb von GetBookmark derselbe Speicher unter zwei Namen sind, und welches Aliasing-Risiko auch immer für das eigene Result einer Funktion gilt, gilt genauso direkt für jede Hilfsroutine, die es per Referenz erhält. Nur die Funktionen zu überprüfen, die wörtlich einen Record-Rückgabetyp deklarieren, übersieht diese Form; die Suche muss auch jedem var- und out-Parameter folgen, an den ein Result weitergereicht wird
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;
Wann ist FillChar weiterhin der richtige Aufruf?
FillChar ist weiterhin korrekt, und oft etwas billiger, für einen Record, der vollständig aus Ordinalzahlen, Fließkomma-Feldern, festgrößigen Arrays davon oder anderen schlichten Records aus demselben Material aufgebaut ist, weil es darin nichts für den Compiler zu finalisieren gibt. PDFiumPas' eigener Rechteck-Typ ist genau dieser Fall: TPdfRectangle hält vier Double-Felder und nichts sonst, und eines mit FillChar zu löschen gibt nichts frei, weil es nichts Referenzgezähltes freizugeben gibt. Die Prüfung, die die zwei Fälle trennt, ist einfach zu formulieren: Hat irgendein Feld des Records, auf welcher Verschachtelungstiefe auch immer, den Typ string, AnsiString, WideString, ein dynamisches Array, ein Interface oder ein Variant? Ein Record kann auf oberster Ebene vollkommen numerisch aussehen und diesen Test trotzdem nicht bestehen, falls eines seiner Felder selbst ein Record ist, der ein paar Ebenen tiefer einen String vergräbt, sodass die Prüfung verschachtelten Records ganz durchfolgen muss, statt bei der äußersten Feldliste stehenzubleiben. Eine bestehende Codebasis auf dieses Muster zu auditieren ist mechanisch, nicht erschöpfend: Nach jedem FillChar-Aufruf suchen, dessen Ziel eine Record-Variable ist, dann die Feldliste dieses Records gegen die obige Liste verwalteter Typen prüfen. PDFiumPas' eigener v1.56.4-Audit führte genau diese Suche über die ganze Bibliothek aus und fand diese Angriffsfläche in einer Unit; jede andere FillChar-Aufrufstelle löschte bereits einen schlichten numerischen Record, wo FillChar das richtige Werkzeug war und bleibt
Dasselbe Compiler-Verhalten, das ein wiederverwendetes Result hier gefährlich macht, treibt auch eine verwandte Familie von Delphi-versus-FPC-Uneinigkeiten anderswo in dieser Codebasis an; ein begleitender Artikel zu Cross-Compiler-Fallstricken behandelt einen Fall, in dem FPC und Delphi sich uneinig sind, genau wann eine Record-Ergebnis-Temporärvariable innerhalb eines einzelnen Ausdrucks finalisiert wird, ein anderes Symptom derselben zugrunde liegenden Tatsache, dass das Record-Result einer Funktion nicht immer der frische, private Speicher ist, als der es erscheint. Die als durchgehendes Beispiel in diesem Artikel verwendete Annotations-Schleife ist auch nicht hypothetisch: Es ist derselbe Seite-für-Seite-Durchlauf, den Sie beim Bau eines Annotations-Review-Panels schreiben würden, genau die Code-Form, die aus einem Ein-Zeilen-FillChar überhaupt erst ein langsames Speicherleck machte
Nichts davon erfordert einen Bibliothekswechsel oder die Jagd nach einem Bug in fremdem kompiliertem Code: Es ist eine Eigenschaft der Object-Pascal-Sprache selbst, mit der jeder Delphi- und FPC-Entwickler täglich arbeitet, und die Lösung ist ein einziger Funktionsaufruf, sobald man weiß, wonach man suchen muss. Die hier beschriebenen Annotations-, Lesezeichen- und Link-Annotations-APIs sind Teil der PDFium-Komponente für Delphi, C++Builder und Lazarus/FPC, zusammen mit dem Rest der PDF-Lese-, Render- und Annotations-Oberfläche, die anderswo in diesem Blog behandelt wird