En Delphi- eller FPC-funktion, der returnerer en record, får ikke en frisk, nulstillet Result ved hvert kald. Den skjulte Result-variabel starter nul præcis én gang, og intet nulstiller den automatisk igen mellem kald, så at rydde den ved indgang er funktionens egen opgave. Gør den oprydning med FillChar(Result, SizeOf(Result), 0), og fra det andet kald og fremefter overskriver rutinen en levende streng- eller dynamisk-array-reference i stedet for at frigive den, og efterlader den heap-blok, referencen pegede på, forældreløs
Scenariet hvor dette bider, er hverdagsagtigt. En batch-proces åbner en stak tredjeparts-PDF'er og gennemgår hver annotation på hver side og trækker kommentar-teksten ind i en audit-log. Intet ved den løkke ser farligt ud: hvert kald er en almindelig funktion, der returnerer en almindelig record, ingen pointers i sigte, intet der ligner manuel hukommelseshåndtering overhovedet. Referenceoptælling inde i en record er en almindelig Object Pascal-regnskabsregel, ikke en særhed specifik for noget enkelt bibliotek, og enhver Delphi- eller FPC-kodebase, der blander FillChar med recordtyper, der holder strenge eller dynamiske arrays, er udsat for den samme defekt
Hvorfor lækker FillChar på et record-resultat strenge?
FillChar lækker strenge, fordi den ikke har nogen anelse om, hvilken slags data den overskriver. FillChar(X, Count, Value) fungerer på en hvilken som helst variabel overhovedet: den tager en utypet blok af Count bytes og stempler hver af dem med Value, og det er hele kontrakten. Det er præcis det, der gør FillChar hurtig og alsidig, fordi den aldrig inspicerer typen af X og aldrig forgrener på, hvad de underliggende bytes betyder. Et UnicodeString- eller WideString-felt inde i en record er ikke tegnene selv; det er en pointer til en heap-blok, der bærer en referenceoptælling forud for tegndataene. FillChar ser en håndfuld bytes, der tilfældigvis holder en pointer-værdi, og overskriver dem med nul præcis, som den ville overskrive et Integer- eller Double-felt. Pointeren forsvinder, referenceoptællingen den skulle have dekrementeret først, røres aldrig, og blokken den pegede på, sidder allokeret med intet tilbage, der refererer den
Hvordan kompileren sporer strenge og dynamiske arrays inde i en record
Object Pascal kalder en type managed, når kompileren skal køre ekstra kode for at holde den korrekt på tværs af tildeling og scope-udgang. Lange strengtyper såsom AnsiString, UnicodeString og WideString kvalificerer, og det gør dynamiske arrays, grænseflader og Varianter også, sammen med enhver record eller fast array, der indeholder en af de som et felt. For hvert managed felt udsender kompileren i stilhed det regnskab, der ellers ville være kedeligt og let at få forkert i hånden: øg en referenceoptælling ved tildeling, sænk den når den holdende variabel overskrives eller går ud af scope, og frigiv den underliggende blok, når den optælling når nul. Det maskineri er, hvorfor almindelig Pascal-kode aldrig manuelt allokerer eller frigiver en string, og hvorfor at tildele ét dynamisk array til et andet er en billig, sikker operation frem for en manuel kopi-løkke. System.Default og Finalize er de to dokumenterede måder at påkalde den samme frigivelseslogik efter behov, og det er, hvad en records oprydningskode bør kalde i stedet for en rå hukommelses-fyldning
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;
Hvorfor begynder lækken først ved det andet kald?
Det første kald i en løkke er altid harmløst, hvilket er præcis, hvad der gør denne defekt let at overse under testning. En lokal variabel af en managed recordtype starter nul, og intet nulstiller den automatisk igen mellem ét løkke-gennemløb og det næste, så første gang en løkke tildeler en funktions returværdi ind i den variabel, er dens Description- eller ContentsText-felt stadig nil. FillChar overskriver nil med nul, hvilket ikke ændrer noget, hvad angår referenceoptællingen, og kaldet returnerer og ser helt korrekt ud. Det andet kald er anderledes: den samme lokale variabel holder allerede, hvad det første kald skrev ind i den, og den nye kalds Result skrives direkte ind i den samme lagring frem for ind i frisk, tom hukommelse. FillChar øverst i det andet kald nulstiller et felt, der ikke længere er nil, og alt nedstrøms af det byte-mønster er i stilhed forkert fra da af. En test der kalder funktionen én gang og inspicerer resultatet, vil aldrig se problemet; kun en løkke, eller enhver kodevej der kalder funktionen gentagne gange mod den samme destination, eksponerer det
Et rigtigt lækage: annotationer, bogmærker og link-records
PDFiumPas leverede præcis den defekt før version 1.56.4, i tre funktioner der hver returnerer en record, der holder mindst ét managed felt: side-niveau-annotationslæseren returnerer en TPdfAnnotation, der bærer ContentsText- og AuthorText-strenge, bogmærke-læseren returnerer en TBookmark, der bærer en Title-streng, og link-annotationslæseren returnerer en TLinkAnnotation, der bærer en ActionPath-streng og et Points-dynamisk-array. Alle tre åbnede med den samme form vist nedenfor: ryd Result med en rå FillChar, udfyld derefter felterne ét ad gangen fra den underliggende sidedata. At gennemgå hver annotation på en side én ad gangen, den almindelige måde at bygge en audit-liste eller et review-panel på, kaldte annotationslæseren i en løkke og lækkede den forrige annotations tekst ved hvert gennemløb efter det første; en PDF udformet med et usædvanligt stort antal tekst-bærende annotationer kunne vokse en langtidskørende proces' hukommelse, så længe den proces blev ved med at køre. Fixen rørte én linje i hver funktion: at erstatte FillChar(Result, SizeOf(Result), 0) med Result := Default(TPdfAnnotation) var nok, fordi at tildele Default til en managed record kører kompilerens almindelige frigiv-så-ryd-sekvens frem for en rå hukommelses-fyldning
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;
Den samme fare bag en var-parameter
Bogmærke-læseren viser en mere subtil version af det samme problem, fordi den record, der ryddes med FillChar, ikke er funktionens egen Result, men en var-parameter ét kald nede. SetBookmarkData tager sit output som var Data: TBookmark og plejede at rydde Data øverst i sin krop med FillChar; GetBookmark, den offentlige funktion der rent faktisk returnerer en TBookmark, kalder SetBookmarkData og sender sin egen Result direkte igennem som det var-argument. En var-parameter sendes ved reference, så Data inde i SetBookmarkData og Result inde i GetBookmark er den samme lagring under to navne, og hvilken aliasing-risiko der end gælder for en funktions egen Result, gælder lige så direkte for enhver hjælperutine, der modtager den ved reference. At gennemgå kun de funktioner, der bogstaveligt erklærer en record-returtype, overser denne form; søgningen skal følge hver var- og out-parameter, en Result videresendes ind i også
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;
Hvornår er FillChar stadig det rigtige kald?
FillChar er stadig korrekt, og ofte en anelse billigere, til en record bygget udelukkende af ordinaler, flydende-komma-felter, faste-størrelse-arrays af de, eller andre almindelige records lavet af det samme, fordi der ikke er noget i den for kompileren at finalisere. PDFiumPas' egen rektangel-type er præcis det tilfælde: TPdfRectangle holder fire Double-felter og intet andet, og at rydde en med FillChar frigiver intet, fordi der ikke er noget referenceoptalt at frigive. Tjekket der adskiller de to tilfælde er simpelt at angive: har noget felt af recorden, ved nogen indlejrings-dybde, typen string, AnsiString, WideString, et dynamisk array, en grænseflade, eller en Variant? En record kan se perfekt numerisk ud på øverste niveau og stadig fejle den test, hvis ét af dens felter selv er en record, der begraver en streng nogle lag nede, så tjekket skal følge indlejrede records hele vejen igennem frem for at stoppe ved den yderste feltliste. At auditere en eksisterende kodebase for dette mønster er mekanisk frem for udtømmende: søg efter hvert FillChar-kald, hvis mål er en record-variabel, tjek derefter den records feltliste mod den managed-type-liste ovenfor. PDFiumPas' egen v1.56.4-audit kørte præcis den søgning på tværs af hele biblioteket og fandt denne eksponering i én unit; hvert andet FillChar-kaldested ryddede allerede en almindelig numerisk record, hvor FillChar var, og forbliver, det rigtige værktøj
Den samme kompiler-opførsel, der gør en genbrugt Result farlig her, driver også en beslægtet familie af Delphi-versus-FPC-uoverensstemmelser andre steder i denne kodebase; en følgeartikel om kryds-kompiler-faldgruber dækker et tilfælde, hvor FPC og Delphi er uenige om præcis, hvornår en record-resultat-midlertidig finaliseres inde i ét enkelt udtryk, et andet symptom på det samme underliggende faktum, at en funktions record-Result ikke altid er den friske, private lagring, den fremstår som. Annotations-løkken brugt som det gennemgående eksempel gennem hele denne artikel er heller ikke hypotetisk: det er den samme side-for-side-gennemgang, man ville skrive, mens man bygger et annotations-review-panel, hvilket er præcis den kodeform, der gjorde en ét-linjes FillChar til en langsom hukommelseslækage i første omgang
Intet af dette kræver at skifte biblioteker eller jagte en bug i en andens kompilerede kode: det er en egenskab ved selve Object Pascal-sproget, en enhver Delphi- og FPC-udvikler arbejder med dagligt, og fixen er ét enkelt funktionskald, når man først ved, hvad man skal kigge efter. Annotations-, bogmærke- og link-annotations-API'erne beskrevet her leveres som en del af PDFium-komponenten til Delphi, C++Builder og Lazarus/FPC, sammen med resten af PDF-læsnings-, gengivelses- og annotations-fladen dækket andre steder på denne blog