Technisch artikel

FillChar op een functieresultaat lekt strings in Object Pascal

Een Delphi- of FPC-functie die een record teruggeeft, krijgt niet bij elke aanroep een verse, genulde Result. Die verborgen Result-variabele begint precies één keer op nul, en niets nult deze automatisch opnieuw tussen aanroepen door, dus deze wissen bij binnenkomst is de eigen taak van de functie. Doe dat wissen met FillChar(Result, SizeOf(Result), 0) en vanaf de tweede aanroep overschrijft de routine een levende string- of dynamische-array-verwijzing in plaats van deze vrij te geven, waardoor het heapblok waar die verwijzing naar wees, verweesd achterblijft

Het scenario waarin dit toeslaat, is alledaags. Een batchproces opent een stapel PDF's van derden en doorloopt elke annotatie op elke pagina, en haalt de commentaartekst naar een auditlog. Niets aan die lus oogt gevaarlijk: elke aanroep is een gewone functie die een gewoon record teruggeeft, geen pointers in zicht, niets dat ook maar op handmatig geheugenbeheer lijkt. Referentietelling binnen een record is een gewone Object-Pascal-boekhoudregel, geen eigenaardigheid specifiek voor één bepaalde bibliotheek, en elke Delphi- of FPC-codebase die FillChar mengt met recordtypen die strings of dynamische arrays bevatten, is blootgesteld aan hetzelfde defect

Waarom lekt FillChar op een recordresultaat strings?

FillChar lekt strings omdat het geen idee heeft welk soort data het overschrijft. FillChar(X, Count, Value) werkt op werkelijk elke variabele: het neemt een ongetypeerd blok van Count bytes en stempelt elk daarvan met Value, en dat is het complete contract. Dat is precies wat FillChar snel en algemeen bruikbaar maakt, omdat het nooit het type van X inspecteert en nooit vertakt op wat de onderliggende bytes betekenen. Een UnicodeString- of WideString-veld binnen een record is niet de tekens zelf; het is een pointer naar een heapblok dat een referentietelling vóór de tekendata draagt. FillChar ziet een handvol bytes die toevallig een pointerwaarde bevatten en overschrijft deze met nul, precies zoals het een Integer- of Double-veld zou overschrijven. De pointer verdwijnt, de referentietelling die eerst had moeten worden verlaagd wordt nooit aangeraakt, en het blok waar hij naar wees blijft toegewezen zonder dat er nog iets naar verwijst

Hoe de compiler strings en dynamische arrays binnen een record volgt

Object Pascal noemt een type beheerd wanneer de compiler extra code moet draaien om het correct te houden over toewijzing en scope-uitgang heen. Lange stringtypen zoals AnsiString, UnicodeString, en WideString komen daarvoor in aanmerking, en zo ook dynamische arrays, interfaces, en Variants, samen met elk record of vast array dat een daarvan als veld bevat. Voor elk beheerd veld genereert de compiler stilzwijgend de boekhouding die anders omslachtig en gemakkelijk verkeerd te doen zou zijn met de hand: een referentietelling verhogen bij toewijzing, deze verlagen wanneer de bezittende variabele wordt overschreven of buiten scope gaat, en het onderliggende blok vrijgeven zodra die telling nul bereikt. Die machinerie is waarom gewone Pascal-code nooit handmatig een string toewijst of vrijgeeft, en waarom het toewijzen van het ene dynamische array aan het andere een goedkope, veilige bewerking is in plaats van een handmatige kopieerlus. System.Default en Finalize zijn de twee gedocumenteerde manieren om diezelfde vrijgavelogica op verzoek aan te roepen, en dat is wat de wiscode van een record zou moeten aanroepen in plaats van een ruwe geheugenvulling

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;

Waarom begint het lek pas bij de tweede aanroep?

De eerste aanroep in een lus is altijd onschadelijk, en precies dat maakt dit defect gemakkelijk over het hoofd te zien tijdens testen. Een lokale variabele van een beheerd recordtype begint op nul, en niets nult deze automatisch opnieuw tussen de ene lusdoorgang en de volgende, dus de eerste keer dat een lus de retourwaarde van een functie aan die variabele toewijst, is het Description- of ContentsText-veld ervan nog steeds nil. FillChar overschrijft nil met nul, wat niets verandert wat de referentietelling betreft, en de aanroep keert terug en ziet er volkomen correct uit. De tweede aanroep is anders: dezelfde lokale variabele bevat al wat de eerste aanroep erin schreef, en de Result van de nieuwe aanroep wordt rechtstreeks in diezelfde opslag geschreven in plaats van in vers, leeg geheugen. FillChar aan het begin van die tweede aanroep nult een veld dat niet langer nil is, en alles stroomafwaarts van dat bytepatroon is vanaf dat moment stilzwijgend verkeerd. Een test die de functie één keer aanroept en het resultaat inspecteert, zal het probleem nooit zien; alleen een lus, of elk codepad dat de functie herhaaldelijk tegen dezelfde bestemming aanroept, stelt het bloot

Een echt lek: annotaties, bladwijzers, en link-records

PDFiumPas leverde precies dit defect vóór versie 1.56.4, in drie functies die elk een record teruggeven met minstens één beheerd veld: de paginaniveau-annotatielezer geeft een TPdfAnnotation terug die de strings ContentsText en AuthorText draagt, de bladwijzerlezer geeft een TBookmark terug die een Title-string draagt, en de link-annotatielezer geeft een TLinkAnnotation terug die een ActionPath-string en een Points-dynamisch-array draagt. Alle drie openden met dezelfde vorm hieronder getoond: Result wissen met een ruwe FillChar, en dan de velden één voor één vullen uit de onderliggende paginadata. Elke annotatie op een pagina één voor één doorlopen, de gewone manier om een auditlijst of een reviewpaneel op te bouwen, riep de annotatielezer in een lus aan en lekte de tekst van de vorige annotatie bij elke doorgang na de eerste; een PDF opgesteld met een ongewoon groot aantal tekstdragende annotaties kon het geheugen van een langlopend proces laten groeien zolang dat proces bleef draaien. De fix raakte één regel in elke functie: FillChar(Result, SizeOf(Result), 0) vervangen door Result := Default(TPdfAnnotation) was genoeg, omdat Default toewijzen aan een beheerd record de gewone vrijgeef-dan-wis-volgorde van de compiler draait in plaats van een ruwe geheugenvulling

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;

Hetzelfde gevaar achter een var-parameter

De bladwijzerlezer toont een subtielere versie van hetzelfde probleem, omdat het record dat met FillChar wordt gewist niet de eigen Result van de functie is, maar een var-parameter één aanroep dieper. SetBookmarkData neemt zijn uitvoer als var Data: TBookmark en wiste voorheen Data aan het begin van zijn body met FillChar; GetBookmark, de publieke functie die daadwerkelijk een TBookmark teruggeeft, roept SetBookmarkData aan en geeft zijn eigen Result rechtstreeks door als dat var-argument. Een var-parameter wordt door referentie doorgegeven, dus Data binnen SetBookmarkData en Result binnen GetBookmark zijn dezelfde opslag onder twee namen, en welk aliasing-risico ook geldt voor de eigen Result van een functie, geldt precies even rechtstreeks voor elke hulproutine die deze door referentie ontvangt. Alleen de functies bekijken die letterlijk een recordretourtype declareren, mist deze vorm; de zoektocht moet ook elke var- en out-parameter volgen waarnaar een Result wordt doorgestuurd

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;

Wanneer is FillChar nog steeds de juiste keuze?

FillChar is nog steeds correct, en vaak iets goedkoper, voor een record dat volledig is opgebouwd uit ordinale, floating-point-velden, vaste-grootte-arrays daarvan, of andere gewone records die uit hetzelfde zijn opgebouwd, omdat er niets in zit dat de compiler moet finaliseren. Het eigen rechthoektype van PDFiumPas is precies dat geval: TPdfRectangle bevat vier Double-velden en niets anders, en er eentje wissen met FillChar geeft niets vrij omdat er niets referentiegeteld is om vrij te geven. De controle die de twee gevallen scheidt, is eenvoudig te formuleren: heeft enig veld van het record, op elke nestdiepte, het type string, AnsiString, WideString, een dynamisch array, een interface, of een Variant? Een record kan er op het bovenste niveau volkomen numeriek uitzien en toch die test niet doorstaan als een van zijn velden zelf een record is dat een paar lagen dieper een string verbergt, dus de controle moet geneste records helemaal volgen in plaats van te stoppen bij de buitenste veldlijst. Een bestaande codebase op dit patroon auditen is mechanisch in plaats van uitputtend: zoek naar elke FillChar-aanroep waarvan het doel een recordvariabele is, en controleer dan de veldlijst van dat record tegen de bovenstaande lijst van beheerde types. De eigen v1.56.4-audit van PDFiumPas draaide precies die zoekopdracht over de hele bibliotheek en vond deze blootstelling in één unit; elke andere FillChar-aanroepplek wiste al een gewoon numeriek record, waar FillChar het juiste gereedschap was, en blijft

Hetzelfde compilergedrag dat een hergebruikte Result hier gevaarlijk maakt, drijft elders in deze codebase ook een verwante familie van meningsverschillen tussen Delphi en FPC aan; een begeleidend artikel over cross-compiler-valkuilen behandelt een geval waarin FPC en Delphi het oneens zijn over precies wanneer een record-resultaat-tijdelijke waarde binnen één enkele expressie wordt gefinaliseerd, een ander symptoom van hetzelfde onderliggende feit dat het record-Result van een functie niet altijd de verse, private opslag is die het lijkt te zijn. De annotatielus die als lopend voorbeeld door dit hele artikel wordt gebruikt, is ook niet hypothetisch: het is dezelfde pagina-voor-pagina-doorloop die u zou schrijven bij het bouwen van een annotatiereviewpaneel, precies de codevorm die een FillChar van één regel om te beginnen tot een langzaam geheugenlek maakte

Niets hiervan vereist het wisselen van bibliotheek of het najagen van een bug in andermans gecompileerde code: het is een eigenschap van de Object-Pascal-taal zelf, waar elke Delphi- en FPC-ontwikkelaar dagelijks mee werkt, en de fix is één enkele functieaanroep zodra u weet waarnaar te zoeken. De hier beschreven annotatie-, bladwijzer-, en link-annotatie-API's maken deel uit van de PDFium Component voor Delphi, C++Builder, en Lazarus/FPC, naast de rest van het PDF-lees-, render-, en annotatieoppervlak dat elders op deze blog wordt behandeld