Funkce v Delphi nebo FPC, která vrací záznam, nedostane při každém volání čerstvý, vynulovaný Result. Tato skrytá proměnná Result začíná na nule přesně jednou, a nic ji mezi voláními automaticky znovu nenuluje, takže vyčištění na vstupu je vlastní úloha funkce. Uděláte-li toto čištění pomocí FillChar(Result, SizeOf(Result), 0), od druhého volání dál rutina přepíše živý odkaz na řetězec nebo dynamické pole místo toho, aby jej uvolnila, a osiří tak jakýkoli blok na haldě, na který tento odkaz ukazoval
Scénář, kde to kouše, je fádní. Dávkový proces otevře hromadu PDF od třetích stran a projde každou anotaci na každé stránce a vytáhne text komentáře do auditního logu. Nic na této smyčce nevypadá nebezpečně: každé volání je obyčejná funkce vracející obyčejný záznam, žádné ukazatele v dohledu, nic, co by připomínalo ruční správu paměti. Počítání referencí uvnitř záznamu je obyčejné účetní pravidlo Object Pascalu, ne zvláštnost specifická pro nějakou knihovnu, a jakákoli codebase v Delphi nebo FPC, která míchá FillChar s typy záznamů nesoucími řetězce nebo dynamická pole, je vystavena stejnému defektu
Proč FillChar na výsledku typu záznam unikne řetězce?
FillChar unikne řetězce, protože nemá ponětí, jaký druh dat přepisuje. FillChar(X, Count, Value) funguje na jakékoli proměnné vůbec: bere netypovaný blok Count bajtů a otiskne na každý z nich Value, a to je celá smlouva. Přesně tohle dělá FillChar rychlým a univerzálním, protože nikdy nekontroluje typ X a nikdy se nevětví podle toho, co podkladové bajty znamenají. Pole typu UnicodeString nebo WideString uvnitř záznamu nejsou samotné znaky; je to ukazatel na blok haldy, který nese počítadlo referencí před znakovými daty. FillChar vidí hrstku bajtů, které náhodou drží hodnotu ukazatele, a přepíše je nulou přesně tak, jako by přepsal pole Integer nebo Double. Ukazatel zmizí, počítadlo referencí, které měl nejdřív snížit, se nikdy nedotkne, a blok, na který ukazoval, sedí alokovaný, aniž by na něj cokoli zbylo odkazovat
Jak kompilátor sleduje řetězce a dynamická pole uvnitř záznamu
Object Pascal nazývá typ spravovaným, když kompilátor musí spustit dodatečný kód, aby jej udržel správný napříč přiřazením a opuštěním rozsahu platnosti. Kvalifikují se dlouhé řetězcové typy jako AnsiString, UnicodeString a WideString, a stejně tak dynamická pole, rozhraní a Variant, spolu s jakýmkoli záznamem nebo polem pevné velikosti, které obsahuje jedno z nich jako pole. Pro každé spravované pole kompilátor tiše vygeneruje účetnictví, které by jinak bylo únavné a snadné pokazit ručně: zvýšit počítadlo referencí při přiřazení, snížit jej, když je držící proměnná přepsána nebo opustí rozsah platnosti, a uvolnit podkladový blok, jakmile toto počítadlo dosáhne nuly. Tento mechanismus je důvod, proč obyčejný kód Pascalu nikdy ručně nealokuje ani neuvolňuje string, a proč je přiřazení jednoho dynamického pole druhému levná, bezpečná operace místo ruční kopírovací smyčky. System.Default a Finalize jsou dva zdokumentované způsoby, jak stejnou uvolňovací logiku vyvolat na požádání, a jsou to ty, které by čisticí kód záznamu měl volat místo surového vyplnění paměti
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;
Proč únik začne až u druhého volání?
První volání ve smyčce je vždy neškodné, což je přesně to, co dělá tento defekt snadno přehlédnutelný při testování. Lokální proměnná spravovaného typu záznam začíná na nule, a nic ji automaticky znovu nenuluje mezi jedním průchodem smyčky a dalším, takže při prvním přiřazení návratové hodnoty funkce do této proměnné ve smyčce je její pole Description nebo ContentsText stále nil. FillChar přepíše nil nulou, což z hlediska počítadla referencí nic nemění, a volání se vrátí vypadající naprosto správně. Druhé volání je jiné: stejná lokální proměnná už drží cokoli, co do ní zapsalo první volání, a Result nového volání se zapisuje přímo do téhož úložiště místo do čerstvé, prázdné paměti. FillChar na začátku tohoto druhého volání vynuluje pole, které už není nil, a všechno po směru od tohoto vzoru bajtů je od té chvíle tiše špatně. Test, který zavolá funkci jednou a zkontroluje výsledek, tento problém nikdy neuvidí; jen smyčka, nebo jakákoli cesta kódu, která volá funkci opakovaně proti stejnému cíli, jej odhalí
Skutečný únik: anotace, záložky a odkazové záznamy
PDFiumPas přesně tento defekt dodával před verzí 1.56.4, ve třech funkcích, z nichž každá vrací záznam nesoucí alespoň jedno spravované pole: čtenář anotací na úrovni stránky vrací TPdfAnnotation nesoucí řetězce ContentsText a AuthorText, čtenář záložek vrací TBookmark nesoucí řetězec Title, a čtenář odkazových anotací vrací TLinkAnnotation nesoucí řetězec ActionPath a dynamické pole Points. Všechny tři začínaly stejným tvarem ukázaným níže: vyčistit Result surovým FillChar, pak postupně naplnit pole z podkladových dat stránky. Průchod každou anotací na stránce po jedné, obyčejný způsob, jak sestavit auditní seznam nebo panel revize, volal čtenáře anotací ve smyčce a při každém průchodu po prvním unikl text předchozí anotace; PDF vytvořené s neobvykle velkým počtem anotací nesoucích text dokázalo nechat paměť dlouho běžícího procesu růst, jak dlouho tento proces běžel. Oprava se dotkla jednoho řádku v každé funkci: nahrazení FillChar(Result, SizeOf(Result), 0) za Result := Default(TPdfAnnotation) stačilo, protože přiřazení Default do spravovaného záznamu spustí obyčejnou sekvenci uvolni-pak-vyčisti kompilátoru místo surového vyplnění paměti
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;
Stejné nebezpečí za parametrem var
Čtenář záložek ukazuje jemnější verzi stejného problému, protože záznam, který se čistí přes FillChar, není vlastní Result funkce, ale parametr var o volání níž. SetBookmarkData bere svůj výstup jako var Data: TBookmark a dřív čistil Data na začátku svého těla přes FillChar; GetBookmark, veřejná funkce, která skutečně vrací TBookmark, volá SetBookmarkData a předává vlastní Result přímo dál jako tento argument var. Parametr var se předává odkazem, takže Data uvnitř SetBookmarkData a Result uvnitř GetBookmark jsou stejné úložiště pod dvěma jmény, a jakékoli riziko aliasingu, které platí pro vlastní Result funkce, platí stejně přímo pro jakoukoli pomocnou rutinu, která jej dostane odkazem. Kontrola jen těch funkcí, které doslova deklarují návratový typ záznam, tento tvar minou; hledání musí sledovat i každý parametr var a out, do kterého se Result přeposílá
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;
Kdy je FillChar pořád správná volba?
FillChar je pořád správná, a často mírně levnější, pro záznam sestavený zcela z ordinálních typů, polí s pohyblivou řádovou čárkou, polí pevné velikosti z těchto typů, nebo jiných obyčejných záznamů sestavených ze stejného, protože v něm není nic, co by kompilátor musel finalizovat. Vlastní typ obdélníku PDFiumPas je přesně tento případ: TPdfRectangle drží čtyři pole Double a nic víc, a vyčištění jednoho přes FillChar neuvolní nic, protože není nic s počítanými referencemi k uvolnění. Kontrola, která rozlišuje tyto dva případy, je jednoduchá vyslovit: má nějaké pole záznamu, na jakékoli hloubce vnoření, typ string, AnsiString, WideString, dynamické pole, rozhraní, nebo Variant? Záznam může na nejvyšší úrovni vypadat naprosto numericky a přesto tímto testem neprojít, pokud je jedno z jeho polí samo záznamem, který o pár vrstev níž pohřbívá řetězec, takže kontrola musí sledovat vnořené záznamy až úplně dolů místo toho, aby se zastavila na nejvzdálenějším seznamu polí. Audit existující codebase na tento vzor je mechanický, ne vyčerpávající: hledejte každé volání FillChar, jehož cíl je proměnná typu záznam, pak zkontrolujte seznam polí tohoto záznamu proti seznamu spravovaných typů výše. Vlastní audit PDFiumPas ve verzi 1.56.4 spustil přesně toto hledání napříč celou knihovnou a našel toto vystavení v jedné jednotce; každé jiné místo volání FillChar už čistilo obyčejný numerický záznam, kde FillChar byl a zůstává správným nástrojem
Stejné chování kompilátoru, které dělá znovu použitý Result zde nebezpečným, také pohání příbuznou rodinu neshod mezi Delphi a FPC jinde v této codebase; doprovodný článek o úskalích napříč kompilátory popisuje případ, kdy se FPC a Delphi neshodnou přesně na tom, kdy se dočasná hodnota výsledku typu záznam finalizuje uvnitř jednoho výrazu, jiný příznak stejného podkladového faktu, že Result funkce typu záznam není vždy tak čerstvé, soukromé úložiště, jak se zdá být. Smyčka anotací použitá jako běžící příklad v celém tomto článku také není hypotetická: je to stejný průchod stránku po stránce, jaký byste napsali při stavbě panelu revize anotací, což je přesně ten tvar kódu, který z jednořádkového FillChar udělal pomalý únik paměti na prvním místě
Nic z tohoto nevyžaduje přepnutí knihoven ani honbu za chybou v cizím zkompilovaném kódu: je to vlastnost samotného jazyka Object Pascal, se kterou denně pracuje každý vývojář v Delphi a FPC, a oprava je jediné volání funkce, jakmile víte, kde hledat. API pro anotace, záložky a odkazové anotace popsané zde se dodává jako součást komponenty PDFium pro Delphi, C++Builder a Lazarus/FPC, spolu se zbytkem plochy čtení, vykreslování a anotací PDF popsané jinde na tomto blogu