Pascalová vazba nad C knihovnou se čte jako obyčejný Pascal. Zavoláte metodu, dostanete zpět záznam, uvolníte, co jste alokovali. Potíž je v tom, že PDFium je knihovna v C a C++ s vlastní volací konvencí, vlastními šířkami celých čísel a vlastními pravidly o tom, kdo vlastní paměť a kdo ji uvolňuje. Nic z toho nepřekročí hranici jazyka samo od sebe. Každá z těchto smluv musí být ručně znovu vyslovena v pascalových deklaracích a jediné špatné slovo promění čistě vypadající volání v poškození zásobníku, oříznutý offset nebo dvojité uvolnění. Audit vazby PDFium Component ve verzi v1.61.0 odhalil po jedné chybě každého druhu. Stojí za to si je projít, protože nejsou specifické pro tuto vazbu. Jsou to stálá nebezpečí zabalení jakéhokoli C API v Delphi nebo Lazarus
cdecl je součástí typu funkce, nikoli ozdoba
Callback s nesprávnou konvencí volání může poškodit zásobník ještě předtím, než se projeví chyba v samotném PDFium, proto musí deklarace Pascalu přesně odpovídat exportovanému C ABI
PDFium je zkompilované C. Na Win32 jeho exporty a, ještě důležitěji, callbacky, které vyvolává, používají volací konvenci cdecl. Při cdecl uklízí zásobník po návratu volání volající strana. Nativním výchozím nastavením Delphi je register a standardem C pro callbacky na Win32 je v některých knihovnách stdcall, kde místo toho uklízí volaná strana. Když struktura předá PDFium ukazatel na funkci a vy na typu tohoto ukazatele zapomenete cdecl, obě strany se neshodnou na tom, kdo upravuje ukazatel zásobníku. Buď jej upraví obě, nebo žádná, a ukazatel zásobníku se při každém vyvolání posune o velikost argumentů
Důvod, proč je tato chyba těžko k nalezení, je ten, že škoda je nelokální. Poškozené volání se vrátí a vypadá v pořádku. Nezarovnání se projeví později, v nějaké nesouvisející funkci, jejíž rámec teď sedí na ukazateli zásobníku, který je o pár bajtů vedle, a projeví se to jako divoké čtení, špatná návratová adresa nebo pád se zpětným trasováním, které neukazuje nikde poblíž callbacku, jejž jste ve skutečnosti pokazili. Vyplňování formulářů je klasické místo, kde to kousne, protože rozhraní pro vyplňování formulářů je záznam plný callbacků, do kterých PDFium volá zpět. Jeden z nich, FFI_OpenFile, předá PDFium funkci, kterou zavolá pro otevření externího souboru, deklarovanou jako function(pThis: PFPDF_FORMFILLINFO; fileFlag: Integer; wsURL: FPDF_WIDESTRING; mode: PAnsiChar): PFPDF_FILEHANDLER; cdecl. Koncové cdecl je tím, co stojí za zkopírování. Vynechte je a kód se stále zkompiluje, stále slinkuje a stále běží až do okamžiku, kdy PDFium tu funkci zavolá. Konvence patří samotnému typu funkce. Není to volitelný cukr a kompilátor vás na její chybějící přítomnost neupozorní, protože prostý typ funkce je naprosto legální pascalový typ. Jedinou obranou je zacházet s volací konvencí jako s povinnou položkou každé importované signatury a každého callbacku, který předáváte ven
size_t má šířku ukazatele, a na FPC Win64 to znamená 64 bitů
Druhá chyba je neshoda šířky celého čísla, která se objeví pouze na jednom cíli. C typ size_t je definován tak, aby byl dostatečně široký na pojmutí velikosti libovolného objektu, což na 64bitové platformě znamená 64bitové celé číslo bez znaménka. Rozhraní PDFium pro progresivní načítání hovoří v bajtových offsetech typu size_t. Záznam FX_FILEAVAIL poskytovatele dostupnosti nese callback IsDataAvail, který PDFium volá s offsetem a velikostí, a callback AddSegment záznamu FX_DOWNLOADHINTS dostává totéž. Oba parametry jsou size_t
IsDataAvail = function(
pThis : PFX_FILEAVAIL;
offset, size: size_t): FPDF_BOOL; cdecl;
AddSegment = procedure(
pThis : PFX_DOWNLOADHINTS;
offset, size: size_t); cdecl;
Pokud tyto offsety deklarujete jako 32bitový typ, vazba funguje na Win32 a na Delphi Win64, a poté tiše selže na FPC a Lazarus Win64. Příčina je rafinovaná. Na FPC Win64 je NativeUInt skutečný 64bitový typ o šířce ukazatele a size_t je na něj aliasován. Vazba má v sekci typů komentář varující právě před zastíněním NativeUInt na FPC, protože jeho předefinování na 32bitový alias by zde vynutilo size_t na 32 bitů a poškodilo každý parametr size_t předaný knihovně nebo jí zapsaný. 64bitový offset dorazivší na 32bitový parametr přijde o svou horní polovinu. Pro malý soubor se každý offset vejde do 32 bitů a nic není špatně. Pro velký soubor, v okamžiku, kdy offset překročí hranici čtyř gigabajtů, ukazuje oříznutá hodnota úplně někam jinam, PDFium se ptá, zda je dostupný špatný rozsah bajtů, a progresivní načítání uvázne nebo čte nesmysly. Chyba je neviditelná, dokud soubor není dostatečně velký a cílem není ten, na kterém se size_t skutečně rozšířil
Pascalová výjimka nikdy nesmí odvíjet skrz C rámec
Třetí třída se týká modelu výjimek, který C nemá. Když PDFium volá jeden z vašich callbacků, váš pascalový kód běží uvnitř zásobníku rámců C a C++, které nevědí nic o mechanismu výjimek Delphi. Pokud váš callback vyvolá výjimku a nechá ji propagovat, odvíjí se skrz rámce, které nikdy nebyly postaveny k odvíjení. Vlastní úklid PDFium se nespustí, jeho vnitřní invarianty zůstanou napůl aktualizované a proces je nyní ve stavu, který knihovna nikdy nepředpokládala. Smlouvou pro tyto callbacky je návratový kód, nikoli výjimka
Dva callbacky to konkretizují. FPDF_FILEWRITE je výlevka, do které PDFium zapisuje uložený dokument, a FPDF_FILEACCESS je zdroj, ze kterého čte vstupní dokument. Oba jsou zde implementovány nad pascalovým TStream a oba mohou selhat tak, jak selhává jakýkoli stream: disk se zaplní, stream se pod vámi zavře, čtení přeběhne za konec. Zapisovací callback obalí svůj zápis do streamu a promění jakékoli selhání v chybový kód PDFium, místo aby je nechal uniknout
function WriteBlock(
pThis: PFPDF_FILEWRITE;
pData: Pointer;
Size : LongWord): Integer; cdecl;
begin
// PDFium považuje jakýkoli návrat jiný než 1 za selhání zápisu. Pascalová
// výjimka nesmí odvíjet skrz tento cdecl/C++ rámec, takže ji zachyťte
// a místo toho ohlaste selhání.
Result := 0;
try
PPdfWrite(pThis).Stream.WriteBuffer(pData^, Size);
Result := 1;
except
end;
end;
Čtecí strana dělá totéž: neúspěšné čtení ohlásí nulu, aby odpovídalo smlouvě FPDF_FILEACCESS, místo aby vyvolalo výjimku přes hranici. Holé except bez opětovného vyvolání vypadá pascalovému programátorovi, vyškolenému nikdy nepolykat výjimky, špatně, a v běžném Pascalu špatné je. Na hranici ABI je to správná podoba, protože jedinou bezpečnou hodnotou, kterou lze vrátit volajícímu v C, je stavový kód, který umí interpretovat. Selhání se stále propaguje, jen prostřednictvím návratové hodnoty, a kód volající nad knihovnou je vynese jako EPdfError, jakmile se řízení vrátí na pascalovou stranu plotu
Dvojité uvolnění se skrývá na chybové cestě
Čtvrtá chyba je vlastnictví. Handle dokumentu PDFium otevírá knihovna a musí být uzavřen přesně jednou, pomocí FPDF_CloseDocument. Nebezpečím je chybová cesta, která uvolní handle, jejž vlastní i jiný úklid. Představte si rutinu, která vytvoří obalový objekt, přiřadí mu čerstvě otevřený handle dokumentu a poté dělá další přípravu, která může selhat. Pokud příprava vyhodí výjimku, obslužná rutina s předčasným návratem, která zavolá FPDF_CloseDocument na surovém handle, jej uzavře, a poté jej destruktor samotného obalového objektu uzavře znovu, až bude objekt uvolněn. Handle je uvolněn dvakrát, což je nedefinované chování a pravděpodobný pád
Audit toto našel na importní cestě stylu vyřazování, která staví TPdf kolem již otevřeného handlu. Oprava spočívá v tom, učinit z převodu vlastnictví jediný zdroj pravdy. Jakmile je handle přiřazen poli obalu, obal jej vlastní a jediným úklidem na chybové cestě je uvolnit obal. Destruktor obalu zavolá FPDF_CloseDocument za vás, takže druhé explicitní uzavření by tentýž dokument uvolnilo dvakrát. Opravená obslužná rutina chyb uvolní objekt a znovu vyvolá výjimku, a existuje přesně jedna cesta k uzavření
Result := TPdf.Create(nil);
try
Result.FDocument := NewDoc; // Result nyní vlastní handle
Result.InitializeFormFill;
Result.ReloadPage;
except
// Result.Free uzavře handle. Druhé FPDF_CloseDocument(NewDoc)
// zde by tentýž dokument PDFium uvolnilo dvakrát.
Result.Free;
raise;
end;
Spravované záznamy i knihovna plná exportů potřebují explicitní úklid
Poslední třída se týká paměti, kterou za vás spravuje kompilátor a kterou C návyk tiše poškodí. Mnoho pomocných funkcí této vazby vrací záznam, který obsahuje WideString nebo dynamické pole. To jsou pole s počítáním referencí a kompilátor vydává skryté účetnictví pro udržování jejich počtů. Instinkt přenesený z C je vynulovat čerstvý záznam pomocí FillChar(Result, SizeOf(Result), 0). To otiskne nuly přes spravovanou referenci uvnitř záznamu, aniž by ji nejprve dekrementovalo. Kompilátor opětovně používá jeden skrytý dočasný objekt pro výsledek funkce napříč iteracemi smyčky, takže při druhé iteraci FillChar přepíše živý ukazatel na řetězec, který nebyl nikdy uvolněn, a řetězec, na který ukazoval, unikne. Zavolejte funkci ve smyčce nad tisícem anotací a uniknou vám tisíce řetězců
Oprava spočívá v tom, nechat jazyk vyčistit záznam způsobem, který umí, pomocí Default(T), které před vynulováním uvolní jakékoli spravované pole
// Default() místo FillChar: kompilátor opětovně používá jeden skrytý dočasný
// objekt pro výsledek funkce napříč iteracemi smyčky, takže FillChar by
// vynuloval živé ukazatele WideString, aniž by je uvolnil.
Result := Default(TPdfAnnotation);
Související problém vlastnictví žije na hranici načítání knihovny. Tato vazba rozliší několik set ukazatelů na funkce z DLL PDFium pomocí GetProcAddress po LoadLibrary. Pokud chybí jeden požadovaný export, částečně navázaný stav je nebezpečný: desítky ukazatelů jsou platné, zbytek je nil nebo zastaralý, a jakékoli pozdější volání přes některý z nich skočí do modulu, který už mohl být uvolněn. Vazba to řeší tím, že knihovnu uvolní a spustí úplné ClearAllBindings, které kdykoli se nepodaří rozlišit požadovaný export, resetuje každý importovaný ukazatel zpět na nil. Poté žádný ukazatel na funkci nevisí do uvolněného modulu a pozdější volání čistě selže na kontrole nil ukazatele namísto větvení do uvolněného kódu
Obal je místem, kde se čtyři smlouvy ručně znovu vyslovují
Žádná z těchto pěti chyb není exotická. Jsou to předvídatelné způsoby selhání tenké pascalové vrstvy nad C API a shlukují se, protože tato vrstva je přesně tam, kde musí být znovu deklarovány čtyři samostatné smlouvy. Volací konvence musí být vepsána jako cdecl na každém callbacku. Šířka celého čísla se musí shodovat se size_t na tom jednom cíli, kde se skutečně rozšiřuje. Model výjimek musí být převeden na návratové kódy na každém callbacku, který přechází ven z Pascalu. Vlastnictví každého handlu a každého spravovaného pole musí být jednou vysloveno a dodrženo na každé cestě, včetně chybových cest, které nikdo neprochází až do produkce. Minete-li kteroukoli z nich, dostanete chybu, jejíž příznak se objeví daleko od její příčiny, což je to, co tuto kategorii činí nákladnou. Hodnota auditu spočívala méně v jakékoli jednotlivé opravě než v zacházení s každou z nich jako s vlastní disciplínou ke kontrole napříč celou vazbou
Chcete-li vidět vazbu při skutečné práci, nikoli při hlídání svých hran, techniky cache vykreslování a přiblížení v naší poznámce o výkonu cache vykreslování a přiblížení ukazují cestu vykreslování, a průvodce napříč kompilátory v sestavení prohlížeče pro Lazarus a FPC je místem, kde zde popsané chování size_t na Win64 skutečně záleží. Oba staví na téže práci na paměťové bezpečnosti a ABI, která je dodávána v PDFium Component pro Delphi, Lazarus a C++Builder, vedle API pro vykreslování, extrakci textu a formuláře pokrytých jinde na tomto blogu