Funkcia v Delphi alebo FPC, ktorá vracia záznam, nedostáva pri každom volaní nový, vynulovaný Result. Táto skrytá premenná Result je vynulovaná presne raz a nič ju medzi jednotlivými volaniami automaticky znovu nevynuluje – vyčistenie na začiatku volania je teda úlohou samotnej funkcie. Ak sa toto čistenie robí príkazom FillChar(Result, SizeOf(Result), 0), potom od druhého volania ďalej rutina neprepisuje živú referenciu na reťazec alebo dynamické pole, ale prepisuje ju priamo – namiesto uvoľnenia tak osirie blok na halde, na ktorý táto referencia ukazovala
Situácia, v ktorej sa toto prejaví, je celkom bežná. Dávkový proces otvorí skupinu PDF dokumentov od tretej strany a prejde všetky anotácie na každej strane, pričom text komentárov ukladá do auditného záznamu. Na takomto cykle na prvý pohľad nie je nič nebezpečné: každé volanie je obyčajná funkcia vracajúca obyčajný záznam, žiadne ukazovatele nikde v dohľade, nič, čo by pripomínalo ručnú správu pamäte. Počítanie referencií vnútri záznamu je bežné pravidlo Object Pascalu, nie zvláštnosť konkrétnej knižnice, a preto je rovnakej chybe vystavená akákoľvek kódová báza v Delphi alebo FPC, ktorá kombinuje FillChar so záznamovými typmi obsahujúcimi reťazce alebo dynamické polia
Prečo FillChar nad výsledkom typu záznam spôsobuje únik reťazcov?
FillChar uniká reťazce preto, lebo vôbec netuší, aký druh dát prepisuje. FillChar(X, Count, Value) funguje nad úplne ľubovoľnou premennou: berie netypizovaný blok Count bajtov a každý z nich prepíše hodnotou Value – to je celý jeho zmysel. Práve vďaka tomu je FillChar rýchly a univerzálny – nikdy neskúma typ X a nikdy sa nerozhoduje podľa toho, čo dané bajty znamenajú. Pole typu UnicodeString alebo WideString vnútri záznamu nie sú samotné znaky – je to ukazovateľ na blok na halde, ktorý pred samotnými znakovými dátami nesie počítadlo referencií. FillChar vidí len hŕstku bajtov, ktoré náhodou obsahujú hodnotu ukazovateľa, a prepíše ich nulou úplne rovnako, ako by prepísal pole typu Integer alebo Double. Ukazovateľ zmizne, počítadlo referencií, ktoré malo byť najprv znížené, sa vôbec nedotkne, a blok, na ktorý ukazoval, zostane alokovaný bez akejkoľvek referencie
Ako kompilátor sleduje reťazce a dynamické polia vnútri záznamu
Object Pascal označuje typ ako spravovaný (managed), keď kompilátor musí spustiť dodatočný kód, aby zostal korektný pri priradení aj pri opustení rozsahu platnosti. Patria sem dlhé reťazcové typy ako AnsiString, UnicodeString a WideString, ako aj dynamické polia, rozhrania a Varianty, spolu s akýmkoľvek záznamom alebo poľom pevnej dĺžky, ktoré niektorý z týchto typov obsahuje ako pole. Pre každé spravované pole kompilátor ticho vygeneruje účtovnícky kód, ktorý by bolo inak zdĺhavé a ľahko chybné písať ručne: pri priradení zvýši počítadlo referencií, pri prepísaní alebo opustení rozsahu platnosti danej premennej ho zníži a po dosiahnutí nuly uvoľní príslušný blok na halde. Práve vďaka tomuto mechanizmu bežný Pascal kód nikdy ručne nealokuje ani neuvoľňuje string, a priradenie jedného dynamického poľa do druhého je lacná a bezpečná operácia, nie ručná kopírovacia slučka. System.Default a Finalize sú dva zdokumentované spôsoby, ako túto rovnakú uvoľňovaciu logiku vyvolať na požiadanie, a práve tie by mal čistiaci kód záznamu volať namiesto surového vyplnenia pamäte
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;
Prečo sa únik prejaví až od druhého volania?
Prvé volanie v cykle je vždy neškodné, a práve to spôsobuje, že sa táto chyba pri testovaní ľahko prehliadne. Lokálna premenná spravovaného záznamového typu začína ako nulová a nič ju medzi jednotlivými priechodmi cyklu automaticky znovu nevynuluje, takže pri prvom priradení návratovej hodnoty funkcie do tejto premennej je jej pole Description alebo ContentsText stále nil. FillChar prepíše nil nulou, čo z hľadiska počítadla referencií nič nemení, a volanie skončí navonok úplne v poriadku. Druhé volanie je iné: tá istá lokálna premenná už obsahuje to, čo do nej zapísalo prvé volanie, a Result nového volania sa zapisuje priamo do toho istého úložiska, nie do čerstvej, prázdnej pamäte. FillChar na začiatku tohto druhého volania vynuluje pole, ktoré už nie je nil, a všetko, čo z tohto bajtového vzoru odteraz vychádza, je potichu nesprávne. Test, ktorý funkciu zavolá raz a skontroluje výsledok, tento problém nikdy neodhalí – odhalí ho len cyklus alebo akákoľvek cesta kódu, ktorá funkciu volá opakovane nad tým istým cieľom
Skutočný únik: anotácie, záložky a odkazové záznamy
Presne túto chybu obsahovala knižnica PDFiumPas pred verziou 1.56.4, v troch funkciách, z ktorých každá vracia záznam s aspoň jedným spravovaným poľom: čítač anotácií na úrovni strany vracia TPdfAnnotation s reťazcami ContentsText a AuthorText, čítač záložiek vracia TBookmark s reťazcom Title a čítač odkazových anotácií vracia TLinkAnnotation s reťazcom ActionPath a dynamickým poľom Points. Všetky tri mali rovnaký tvar, aký je zobrazený nižšie: vyčistiť Result surovým FillChar a potom postupne naplniť polia z podkladových dát strany. Prechádzanie všetkých anotácií na strane po jednej – bežný spôsob, ako zostaviť auditný zoznam alebo kontrolný panel – volalo čítač anotácií v cykle a pri každom priechode po prvom uniklo text predchádzajúcej anotácie; PDF s neobvykle vysokým počtom textových anotácií tak mohol počas celej doby behu dlho bežiaceho procesu neustále zväčšovať jeho spotrebu pamäte. Oprava sa dotkla jediného riadka v každej funkcii: nahradenie FillChar(Result, SizeOf(Result), 0) príkazom Result := Default(TPdfAnnotation) stačilo, pretože priradenie Default spravovanému záznamu spustí bežnú kompilátorom generovanú sekvenciu uvoľnenia a následného vyčistenia namiesto surového vyplnenia pamäte
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;
Rovnaké riziko za parametrom var
Čítač záložiek ukazuje jemnejšiu variantu toho istého problému, pretože záznam, ktorý sa čistí pomocou FillChar, nie je vlastný Result funkcie, ale var parameter o volanie nižšie. SetBookmarkData prijíma svoj výstup ako var Data: TBookmark a na začiatku svojho tela kedysi čistila Data pomocou FillChar; GetBookmark, verejná funkcia, ktorá skutočne vracia TBookmark, volá SetBookmarkData a odovzdáva jej vlastný Result priamo ako tento var argument. Parameter var sa odovzdáva odkazom, takže Data vnútri SetBookmarkData a Result vnútri GetBookmark sú to isté úložisko pod dvoma menami, a akékoľvek riziko aliasovania platné pre vlastný Result funkcie platí rovnako priamo aj pre akúkoľvek pomocnú rutinu, ktorá ho dostáva odkazom. Kontrola len tých funkcií, ktoré doslova deklarujú návratový typ záznam, tento prípad prehliadne – hľadanie musí sledovať aj každý parameter var a out, do ktorého sa Result ďalej odovzdáva
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;
Kedy je FillChar stále správnou voľbou?
FillChar je stále korektný, a často o niečo lacnejší, pre záznam zostavený výlučne z ordinálnych typov, čísiel s pohyblivou rádovou čiarkou, polí pevnej veľkosti z týchto typov alebo z ďalších obyčajných záznamov zloženych z toho istého, pretože v ňom nie je nič, čo by kompilátor musel finalizovať. Presne takýmto prípadom je vlastný typ obdĺžnika v PDFiumPas: TPdfRectangle obsahuje štyri polia Double a nič iné, takže jeho vyčistenie pomocou FillChar nič neuvoľňuje, pretože nie je čo počítať referenciami. Kontrola, ktorá tieto dva prípady rozlišuje, sa dá formulovať jednoducho: má niektoré pole záznamu, na ľubovoľnej úrovni zanorenia, typ string, AnsiString, WideString, dynamické pole, rozhranie alebo Variant? Záznam môže na najvyššej úrovni vyzerať úplne číselne, a napriek tomu tento test neprejde, ak je niektoré jeho pole samo záznamom, ktorý o niekoľko úrovní nižšie skrýva reťazec – kontrola preto musí sledovať zanorené záznamy až do konca a nesmie sa zastaviť pri zozname polí najvyššej úrovne. Audit existujúcej kódovej bázy na tento vzor je mechanický, nie vyčerpávajúci: stačí vyhľadať každé volanie FillChar, ktorého cieľom je premenná typu záznam, a potom porovnať zoznam polí tohto záznamu s vyššie uvedeným zoznamom spravovaných typov. Práve takéto vyhľadávanie prebehlo pri audite PDFiumPas k verzii v1.56.4 naprieč celou knižnicou a odhalilo toto riziko v jednej jednotke; všetky ostatné výskyty FillChar už čistili obyčajný číselný záznam, kde bol a zostáva FillChar správnym nástrojom
Rovnaké správanie kompilátora, ktoré tu robí opätovne používaný Result nebezpečným, stojí aj za ďalšou skupinou nezhôd medzi Delphi a FPC inde v tejto kódovej báze; sprievodný článok o úskaliach medzi kompilátormi pokrýva prípad, kde sa FPC a Delphi nezhodnú v tom, kedy presne sa dočasná hodnota návratového záznamu vnútri jedného výrazu finalizuje – iný symptóm rovnakého základného faktu, že návratový Result funkcie typu záznam nie je vždy tým čerstvým, súkromným úložiskom, akým sa javí. Ani cyklus s anotáciami použitý v tomto článku ako sprievodný príklad nie je hypotetický: ide o rovnaké prechádzanie strana po strane, aké by ste napísali pri vytváraní panela na kontrolu anotácií, čo je presne ten tvar kódu, ktorý raz zmenil jednoriadkový FillChar na pomalý únik pamäte
Nič z toho si nevyžaduje výmenu knižnice ani hľadanie chyby v cudzom skompilovanom kóde: ide o vlastnosť samotného jazyka Object Pascal, s ktorou pracuje každý deň každý vývojár v Delphi či FPC, a oprava je jediné volanie funkcie, akonáhle viete, kde ho hľadať. API pre anotácie, záložky a odkazové anotácie opísané v tomto článku sú súčasťou komponentu PDFium Component pre Delphi, C++Builder a Lazarus/FPC, spolu so zvyškom funkcií na čítanie, vykresľovanie a anotovanie PDF opísaných inde na tomto blogu