Technický článek

FillChar na výsledku funkce uniká řetězce v Object Pascalu

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