Teknisk artikel

FillChar på ett funktionsresultat läcker strängar i Object Pascal

En Delphi- eller FPC-funktion som returnerar en post får inte ett färskt, nollställt Result vid varje anrop. Den dolda Result-variabeln startar noll exakt en gång, och inget nollställer den automatiskt igen mellan anrop, så att rensa den vid ingång är funktionens eget jobb. Gör den rensningen med FillChar(Result, SizeOf(Result), 0) och, från och med det andra anropet, skriver rutinen över en levande sträng- eller dynamisk-array-referens istället för att frigöra den, och lämnar det heap-block referensen pekade på föräldralöst

Scenariot där det här biter är vardagligt. En batchprocess öppnar en stapel tredjepartsPDF:er och går igenom varje anteckning på varje sida, drar ut kommentartexten till en granskningslogg. Inget om den loopen ser farligt ut: varje anrop är en vanlig funktion som returnerar en vanlig post, inga pekare i sikte, inget som liknar manuell minneshantering alls. Referensräkning inuti en post är en vanlig Object Pascal-bokföringsregel, inte en egenhet specifik för något enskilt bibliotek, och vilken Delphi- eller FPC-kodbas som helst som blandar FillChar med posttyper som håller strängar eller dynamiska arrayer är exponerad för samma defekt

Varför läcker FillChar på ett postresultat strängar?

FillChar läcker strängar eftersom den inte har någon aning om vilken typ av data den skriver över. FillChar(X, Count, Value) fungerar på vilken variabel som helst: den tar ett otypat block av Count byte och stämplar var och en av dem med Value, och det är hela kontraktet. Det är precis vad som gör FillChar snabb och generell, eftersom den aldrig inspekterar typen av X och aldrig förgrenar sig baserat på vad de underliggande byten betyder. Ett UnicodeString- eller WideString-fält inuti en post är inte tecknen själva; det är en pekare till ett heap-block som bär en referensräkning framför teckendatan. FillChar ser en handfull byte som råkar hålla ett pekarvärde och skriver över dem med noll exakt som den skulle skriva över ett Integer- eller Double-fält. Pekaren försvinner, referensräkningen den borde ha minskat först rörs aldrig, och blocket den pekade på ligger allokerat utan något kvar som refererar till det

Hur kompilatorn spårar strängar och dynamiska arrayer inuti en post

Object Pascal kallar en typ hanterad när kompilatorn måste köra extra kod för att hålla den korrekt över tilldelning och räckviddsutgång. Långa strängtyper som AnsiString, UnicodeString, och WideString kvalificerar, och det gör även dynamiska arrayer, gränssnitt, och Varianter, tillsammans med varje post eller fast array som innehåller en av de som fält. För varje hanterat fält skriver kompilatorn tyst ut bokföringen som annars skulle vara tråkig och lätt att få fel för hand: öka en referensräkning vid tilldelning, minska den när den hållande variabeln skrivs över eller går ur räckvidd, och frigör det underliggande blocket när den räkningen når noll. Det maskineriet är varför vanlig Pascal-kod aldrig manuellt allokerar eller frigör en string, och varför att tilldela en dynamisk array till en annan är en billig, säker operation snarare än en manuell kopieringsloop. System.Default och Finalize är de två dokumenterade sätten att anropa samma frigöringslogik på begäran, och de är vad en posts rensningskod borde anropa istället för en rå minnesfyllning

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;

Varför börjar läckan bara vid det andra anropet?

Det första anropet i en loop är alltid ofarligt, vilket är precis vad som gör den här defekten lätt att missa vid testning. En lokal variabel av en hanterad posttyp startar noll, och inget nollställer den automatiskt igen mellan en loop-passering och nästa, så första gången en loop tilldelar en funktions returvärde till den variabeln är dess Description- eller ContentsText-fält fortfarande nil. FillChar skriver över nil med noll, vilket inte ändrar något vad gäller referensräkningen, och anropet returnerar och ser helt korrekt ut. Det andra anropet är annorlunda: samma lokala variabel håller redan vad det första anropet skrev in i den, och det nya anropets Result skrivs direkt in i samma lagring snarare än in i färskt, tomt minne. FillChar i toppen av det andra anropet nollställer ett fält som inte längre är nil, och allt nedströms om det bytemönstret är tyst fel från och med då. Ett test som anropar funktionen en gång och inspekterar resultatet kommer aldrig se problemet; bara en loop, eller vilken kodväg som helst som anropar funktionen upprepade gånger mot samma destination, exponerar det

En riktig läcka: anteckningar, bokmärken, och länkposter

PDFiumPas levererade precis den här defekten före version 1.56.4, i tre funktioner som var och en returnerar en post som håller minst ett hanterat fält: sidnivå-anteckningsläsaren returnerar en TPdfAnnotation som bär ContentsText- och AuthorText-strängar, bokmärkesläsaren returnerar en TBookmark som bär en Title-sträng, och länkanteckningsläsaren returnerar en TLinkAnnotation som bär en ActionPath-sträng och en Points-dynamisk array. Alla tre öppnade med samma form visad nedan: rensa Result med en rå FillChar, fyll sedan i fälten ett i taget från den underliggande sidatan. Att gå igenom varje anteckning på en sida en i taget, det vanliga sättet att bygga en granskningslista eller en granskningspanel, anropade anteckningsläsaren i en loop och läckte föregående antecknings text vid varje passering efter den första; en PDF konstruerad med ett ovanligt stort antal textbärande anteckningar kunde få en långkörande process minnesanvändning att växa så länge den processen fortsatte köra. Fixen rörde en rad i varje funktion: att ersätta FillChar(Result, SizeOf(Result), 0) med Result := Default(TPdfAnnotation) räckte, eftersom att tilldela Default till en hanterad post kör kompilatorns vanliga frigör-sedan-rensa-sekvens istället för en rå minnesfyllning

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;

Samma fara bakom en var-parameter

Bokmärkesläsaren visar en subtilare version av samma problem, eftersom posten som rensas med FillChar inte är funktionens eget Result utan en var-parameter ett anrop ner. SetBookmarkData tar sin utdata som var Data: TBookmark och brukade rensa Data i toppen av sin kropp med FillChar; GetBookmark, den publika funktionen som faktiskt returnerar en TBookmark, anropar SetBookmarkData och skickar sitt eget Result rakt igenom som det var-argumentet. En var-parameter skickas via referens, så Data inuti SetBookmarkData och Result inuti GetBookmark är samma lagring under två namn, och vilken aliasingsrisk som än gäller för en funktions eget Result gäller precis lika direkt för vilken hjälprutin som helst som tar emot det via referens. Att granska bara funktionerna som bokstavligen deklarerar en postreturtyp missar den här formen; sökningen måste följa varje var- och out-parameter som ett Result matas in i också

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;

När är FillChar fortfarande rätt anrop?

FillChar är fortfarande korrekt, och ofta lite billigare, för en post byggd helt av ordinaler, flyttalsfält, fast storlek-arrayer av dessa, eller andra vanliga poster gjorda av samma, eftersom det inte finns något i den för kompilatorn att finalisera. PDFiumPas egen rektangeltyp är precis det fallet: TPdfRectangle håller fyra Double-fält och inget annat, och att rensa en med FillChar frigör ingenting eftersom det inte finns något referensräknat att frigöra. Kontrollen som skiljer de två fallen är enkel att formulera: har något fält i posten, på vilket kapslingsdjup som helst, typen string, AnsiString, WideString, en dynamisk array, ett gränssnitt, eller en Variant? En post kan se helt numerisk ut på toppnivån och ändå misslyckas det testet om ett av dess fält självt är en post som begraver en sträng några lager ner, så kontrollen måste följa kapslade poster hela vägen igenom snarare än att stanna vid den yttersta fältlistan. Att granska en befintlig kodbas för det här mönstret är mekaniskt snarare än uttömmande: sök efter varje FillChar-anrop vars mål är en postvariabel, kontrollera sedan den postens fältlista mot den hanterade-typ-listan ovan. PDFiumPas egen v1.56.4-granskning körde precis den sökningen över hela biblioteket och hittade den här exponeringen i en enhet; varje annan FillChar-anropsplats rensade redan en vanlig numerisk post, där FillChar var, och förblir, rätt verktyg

Samma kompilatorbeteende som gör ett återanvänt Result farligt här driver också en relaterad familj av Delphi-mot-FPC-oenigheter någon annanstans i den här kodbasen; en följeartikel om korskompilator-fällor täcker ett fall där FPC och Delphi är oense om exakt när en post-resultat-tillfällig variabel finaliseras inuti ett enda uttryck, ett annat symtom på samma underliggande faktum att en funktions postResult inte alltid är den färska, privata lagring den ser ut att vara. Anteckningsloopen använd som det löpande exemplet genom den här artikeln är heller inte hypotetisk: det är samma sida-för-sida-genomgång du skulle skriva medan du bygger en anteckningsgranskningspanel, vilket är precis den kodformen som förvandlade en enradig FillChar till en långsam minnesläcka från första början

Inget av detta kräver att byta bibliotek eller jaga en bugg i någon annans kompilerade kod: det är en egenskap hos själva Object Pascal-språket, en varje Delphi- och FPC-utvecklare arbetar med dagligen, och fixen är ett enda funktionsanrop när man väl vet att leta efter det. Anteckningens, bokmärkets, och länkanteckningens API:er som beskrivs här levereras som en del av PDFium-komponenten för Delphi, C++Builder, och Lazarus/FPC, tillsammans med resten av PDF-läsnings-, rendrerings-, och anteckningsytan som täcks på annat håll på den här bloggen