Pascalovská vazba nad C knihovnou vypadá jako běžný Pascal. Zavoláte metodu, obdržíte záznam a uvolníte to, co jste alokovali. Problém 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 samo o sobě nepřekračuje hranici mezi jazyky. Každý z těchto kontraktů musí být ručně deklarován v definicích Pascalu a jediné chybné slovo promění čistě vypadající volání v poškození zásobníku, oříznutý offset nebo dvojité uvolnění paměti. Audit verze v1.61.0 VCL vazby pro PDFium odhalil po jedné chybě od každého druhu. Stojí za to je projít, protože nejsou specifické pro tuto konkrétní vazbu. Představují stálá rizika při obalování jakéhokoli C API v Delphi nebo Lazarus.
cdecl je součástí typu funkce, nikoli jen ozdobou
PDFium je kompilované C. Na Win32 jeho exporty a především callbacky, které vyvolává, používají volací konvenci cdecl. Při konvenci cdecl čistí zásobník volající poté, co se volání vrátí. Výchozím nastavením Delphi is register a standardem Win32 C pro callbacky je v některých knihovnách stdcall, kde zásobník naopak čistí volaná funkce. Pokud struktura předá PDFium ukazatel na funkci a vy zapomenete uvést cdecl u typu tohoto ukazatele, obě strany se neshodnou na tom, kdo upraví 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č se tato chyba těžko hledá, je ten, že poškození je nelokální. Poškozené volání se vrátí a vypadá v pořádku. Nesoulad se projeví až později, v nějaké nesouvisející funkci, jejíž rámec nyní leží na ukazateli zásobníku, který je posunut o několik bajtů. Projevuje se to jako neplatné čtení, špatná návratová adresa nebo pád s výpisem zásobníku (backtrace), který ukazuje zcela mimo callback, v němž byla chyba. Vyplňování formulářů je klasické místo, kde k tomu dochází, protože rozhraní pro vyplňování formulářů je záznam plný callbacků, které PDFium volá zpět. Jeden z nich, FFI_OpenFile, předává PDFium funkci, kterou bude volat 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 klíčovým detailem. Vynechejte jej a kód se stále zkompiluje, sestaví a spustí, a to až do okamžiku, kdy PDFium danou funkci zavolá. Konvence patří k samotnému typu funkce. Není to volitelný doplněk a překladač vás na chybějící konvenci neupozorní, protože prostý typ funkce je v Pascalu zcela legální. Jedinou obranou je zacházet s volací konvencí jako s povinným polem 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ů
Druhou chybou je nesoulad šířky celého čísla, který se objevuje pouze na jedné platformě. Typ size_t v jazyce C je definován tak, aby byl dostatečně široký pro jakoukoli velikost objektu, což na 64bitové platformě znamená 64bitové celé číslo bez znaménka. Rozhraní pro progresivní načítání PDFium komunikují 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 přijímá 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 Delphi Win64, ale tiše selže na FPC a Lazarus Win64. Příčina je subtilní. Na FPC Win64 je NativeUInt skutečný 64bitový typ se šířkou ukazatele a size_t je pro něj aliasem. Vazba obsahuje v sekci typů komentář varující právě před stíněním NativeUInt na FPC, protože jeho redefinice na 32bitový alias by zde vynutila 32bitový size_t a poškodila by každý parametr size_t předávaný knihovně nebo jí zapisovaný. 64bitový offset přicházející do 32bitového parametru ztratí svou horní polovinu. U malého souboru se každý offset vejde do 32 bitů a nic se neděje. U velkého souboru ve chvíli, kdy offset překročí hranici čtyř gigabajtů, oříznutá hodnota ukazuje úplně jinam, PDFium se dotazuje, zda je k dispozici nesprávný rozsah bajtů, a progresivní načítání se zastaví nebo čte nesmysly. Chyba je neviditelná, dokud soubor není dostatečně velký a cílovou platformou není ta, kde se size_t skutečně rozšířil.
Výjimka v Pascalu nesmí nikdy projít přes 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áš kód v Pascalu běží uvnitř zásobníku C a C++ rámců, které nevědí nic o mechanismu výjimek Delphi. Pokud váš callback vyvolá výjimku a nechá ji propagovat, prochází (unwind) rámce, které pro to nebyly nikdy navrženy. Vlastní vyčištění PDFium neproběhne, jeho interní stav zůstane napůl nekonzistentní a proces se ocitne ve stavu, který knihovna nikdy nepředpokládala. Kontrakten pro tyto callbacky je návratový kód, nikoli výjimka. Dva callbacky to ilustrují konkrétně. FPDF_FILEWRITE je cíl, do kterého PDFium zapisuje uložený dokument, a FPDF_FILEACCESS je zdroj, ze kterého načítá vstupní dokument. Oba jsou zde implementovány nad Delphi TStream a oba mohou selhat způsobem, jakým selhává jakýkoliv stream: disk se zaplní, stream se pod vámi uzavře, čtení se dostane za konec. Callback pro zápis obaluje zápis do streamu a převádí jakékoli selhání na chybový kód PDFium, místo aby jej nechal uniknout.
function WriteBlock(
pThis: PFPDF_FILEWRITE;
pData: Pointer;
Size : LongWord): Integer; cdecl;
begin
// PDFium treats any non-1 return as a write failure. A Pascal exception
// must not unwind through this cdecl/C++ frame, so trap it and report
// failure instead.
Result := 0;
try
PPdfWrite(pThis).Stream.WriteBuffer(pData^, Size);
Result := 1;
except
end;
end;
Strana čtení dělá totéž: selhání čtení vrátí nulu, aby to odpovídalo kontraktu FPDF_FILEACCESS, namísto vyvolání výjimky přes hranici rozhraní. Prázdný blok except bez opětovného vyvolání výjimky vypadá pro programátora v Pascalu, který byl naučen výjimky nikdy nepotlačovat, nesprávně. V běžném Pascalu to také chyba je. Na hranici ABI je to však správný tvar, protože jedinou bezpečnou hodnotou, kterou lze C volajícímu vrátit, je stavový kód, který umí interpretovat. Selhání se stále šíří, ale prostřednictvím návratové hodnoty, a volající kód nad knihovnou jej vyvede na povrch jako EPdfError, jakmile se řízení vrátí na stranu Pascalu.
Dvojité uvolnění se skrývá v chybové cestě
Čtvrtou chybou je vlastnictví. Handle dokumentu PDFium otevírá knihovna a musí být uzavřen přesně jednou, a to pomocí FPDF_CloseDocument. Nebezpečím je chybová cesta, která uvolní handle, jejž vlastní také druhý mechanismus vyčištění. Představte si rutinu, která vytvoří obalový objekt, přiřadí mu nově otevřený handle dokumentu a poté provede další nastavení, které může selhat. Pokud toto nastavení vyvolá výjimku, obsluha předčasného návratu, která zavolá FPDF_CloseDocument na surový handle, jej uzavře a poté jej vlastní destruktor obalového objektu uzavře znovu při uvolnění objektu. Handle je uvolněn dvakrát, což je nedefinované chování a pravděpodobně to způsobí pád programu. Audit toto odhalil na importní cestě typu imposition, která staví TPdf kolem již otevřeného handlu. Nápravou je učinit z přenosu vlastnictví jediný zdroj pravdy. Jakmile je handle přiřazen do pole obalového objektu, obal jej vlastní a jediným vyčištěním v chybové cestě je uvolnění obalu. Destruktor obalového objektu volá FPDF_CloseDocument za vás, takže druhé explicitní uzavření by vedlo k dvojitému uvolnění stejného dokumentu. Opravená obsluha chyb uvolní objekt a znovu vyvolá výjimku. Existuje tak právě jedna cesta k uzavření.
Result := TPdf.Create(nil);
try
Result.FDocument := NewDoc; // Result now owns the handle
Result.InitializeFormFill;
Result.ReloadPage;
except
// Result.Free closes the handle. A second FPDF_CloseDocument(NewDoc)
// here would double-free the same PDFium document.
Result.Free;
raise;
end;
Spravované záznamy i knihovna plná exportů vyžadují explicitní rozebrání
Poslední třída se týká paměti, kterou překladač spravuje za vás a kterou zvyk z jazyka C může tiše pohodit. Mnoho pomocných funkcí této vazby vrací záznam (record), který obsahuje WideString nebo dynamické pole. Jedná se o pole s počítáním referencí a překladač generuje skryté operace pro udržování těchto počtů. Instinkt převzatý z C velí vyčistit nový záznam pomocí FillChar(Result, SizeOf(Result), 0). To však přepíše spravovanou referenci uvnitř záznamu nulami, aniž by ji nejprve dekrementovalo. Překladač znovu používá jednu skrytou dočasnou proměnnou 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. Řetězec, na který ukazoval, tak unikne z paměti. Zavolejte tuto funkci ve smyčce nad tisíci anotacemi a unikne vám tisíc řetězců. Nápravou je nechat jazyk vyčistit záznam způsobem, jakým to umí, tedy pomocí Default(T), což uvolní jakékoli spravované pole před jeho vynulováním.
// Default() instead of FillChar: the compiler reuses one hidden temp for
// the function result across loop iterations, so FillChar would zero live
// WideString pointers without releasing them.
Result := Default(TPdfAnnotation);
Související problém s vlastnictvím existuje na hranici načítání knihovny. Tato vazba po LoadLibrary překládá několik stovek ukazatelů na funkce z DLL PDFium pomocí GetProcAddress. Pokud chybí jeden vyžadovaný export, je částečně propojený stav nebezpečný: desítky ukazatelů jsou platné, zbytek je nil nebo neaktuální a jakékoli pozdější volání přes jeden z nich skočí do modulu, který již může být uvolněn z paměti. Vazba to řeší tak, že uvolní knihovnu a spustí kompletní ClearAllBindings, která resetuje každý importovaný ukazatel zpět na nil, kdykoli se nepodaří přeložit požadovaný export. Poté již žádný ukazatel na funkci neukazuje do uvolněného modulu a pozdější volání selže čistě na kontrole nil ukazatele, namísto větvení do uvolněného kódu.
Obal je místem, kde se ručně deklarují čtyři kontrakty
Žádná z těchto pěti chyb není exotická. Jsou to předvídatelné způsoby selhání tenké vrstvy Pascalu nad C API. Hromadí se proto, že tato vrstva je přesně místem, kde musí být znovu deklarovány čtyři samostatné kontrakty. Volací konvence musí být označena jako cdecl u každého callbacku. Šířka celého čísla musí odpovídat size_t na platformě, kde se skutečně rozšiřuje. Model výjimek musí být převeden na návratové kódy u každého callbacku, který překračuje hranice Pascalu. Vlastnictví každého handlu a každého spravovaného pole musí být jasně určeno a dodržováno v každé cestě, včetně chybových cest, které nikdo netestuje až do ostrého provozu. Vynecháte-li kteroukoli z těchto částí, získáte chybu, jejíž symptom se projeví daleko od své příčiny. To je to, co činí tuto kategorii tak nákladnou. Hodnota auditu spočívala méně v jakékoli konkrétní opravě než v tom, že s každým z těchto bodů zacházel jako s vlastní disciplínou, kterou je třeba zkontrolovat v celé vazbě.
Chcete-li vidět vazbu dělat skutečnou práci, a ne jen hlídat své hranice, techniky kešování vykreslování a zoomu v naší poznámce o kešování vykreslování a výkonu zoomu ukazují vykreslovací cestu a průvodce křížovou kompilací při tvorbě prohlížeče v Lazarus a FPC ukazuje místo, kde na chování Win64 size_t popsaném zde skutečně záleží. Oba postupy staví na stejné práci s bezpečností paměti a ABI, která je dodávána v PDFium Component pro Delphi, Lazarus a C++Builder společně s rozhraními API pro vykreslování, extrakci textu a formuláře popsanými jinde na tomto blogu.