Pascal binding nad C knižnicou sa číta ako obyčajný Pascal. Zavoláte metódu, dostanete späť záznam, uvoľníte to, čo ste alokovali. Problém je v tom, že PDFium je knižnica v C a C++ s vlastnou volacou konvenciou, vlastnými šírkami celých čísel a vlastnými pravidlami o tom, kto vlastní pamäť a kto ju uvoľňuje. Nič z toho neprekračuje hranicu jazyka samo od seba. Každý z týchto kontraktov musí byť ručne prepísaný v deklaráciách Pascalu a jediné chybné slovo premení čisto vyzerajúce volanie na korupciu zásobníka (stack corruption), orezaný offset alebo dvojité uvoľnenie (double free). Audit verzie v1.61.0 pre VCL binding pre PDFium odhalil po jednej chybe z každého druhu. Stojí za to si ich prejsť, pretože nie sú špecifické pre tento binding. Sú to neustále hrozby pri obalovaní akéhokoľvek C API v Delphi alebo Lazaruse.
cdecl je súčasťou typu funkcie, nie dekorácia
PDFium je kompilované C. Na systéme Win32 jeho exporty a hlavne spätné volania (callbacks), ktoré spúšťa, používajú volaciu konvenciu cdecl. Pri cdecl čistí zásobník volajúci po návrate volania. Natívne predvolené nastavenie Delphi je register a štandard Win32 C pre spätné volania je v niektorých knižniciach stdcall, kde zásobník namiesto toho čistí volaná funkcia. Keď štruktúra odovzdá PDFium ukazovateľ na funkciu a vy zabudnete na cdecl v type tohto ukazovateľa, obe strany sa nezhodnú na tom, kto upravuje ukazovateľ zásobníka. Buď ho opravia obe strany, alebo ani jedna, a ukazovateľ zásobníka sa pri každom volaní posunie o veľkosť argumentov.
Dôvod, prečo je táto chyba ťažko lokalizovateľná, je ten, že poškodenie je nelokálne. Poškodené volanie sa vráti a vyzerá v poriadku. Nesúlad sa prejaví neskôr v nejakej nesúvisiacej funkcii, ktorej rámec teraz sedí na ukazovateli zásobníka, ktorý je o niekoľko bajtov mimo, a prejavuje sa ako náhodné čítanie, zlá návratová adresa alebo pád s backtrace, ktorý neukazuje nikde blízko spätného volania, ktoré ste v skutočnosti pokazili. Vypĺňanie formulárov (form-fill) is klasické miesto, kde to bolí, pretože rozhranie pre vypĺňanie formulárov je záznam plný spätných volaní, ktoré PDFium volá späť. Jedno z nich, FFI_OpenFile, odovzdáva PDFium funkciu, ktorú zavolá na otvorenie externého súboru, deklarovanú ako function(pThis: PFPDF_FORMFILLINFO; fileFlag: Integer; wsURL: FPDF_WIDESTRING; mode: PAnsiChar): PFPDF_FILEHANDLER; cdecl. Koncový cdecl je bod, ktorý stojí za to skopírovať. Vynechajte ho a kód sa stále skompiluje, stále zlinkuje a stále beží až do momentu, kedy PDFium zavolá danú funkciu. Konvencia patrí k samotnému typu funkcie. Nie je to nepovinný cukor a kompilátor vás neupozorní na jej absenciu, pretože obyčajný typ funkcie je dokonale legálny typ v Pascale. Jedinou obranou je považovať volaciu konvenciu za povinné pole každého importovaného podpisu a každého spätného volania, ktoré odovzdávate von.
size_t má šírku ukazovateľa a na FPC Win64 to znamená 64 bitov
Druhá chyba je nesúlad šírky celého čísla, ktorý sa prejavuje iba na jednom cieľovom systéme. Typ size_t v jazyku C je definovaný tak, aby bol dostatočne široký na uloženie akejkoľvek veľkosti objektu, čo na 64-bitovej platforme znamená 64-bitové bezznamienkové celé číslo. Rozhrania progresívneho načítavania v PDFium komunikujú v bajtových offsetoch typu size_t. Záznam poskytovateľa dostupnosti FX_FILEAVAIL nesie spätné volanie IsDataAvail, ktoré PDFium volá s offsetom a veľkosťou, a spätné volanie AddSegment záznamu FX_DOWNLOADHINTS prijíma to isté. Oba parametre sú typu size_t.
IsDataAvail = function(
pThis : PFX_FILEAVAIL;
offset, size: size_t): FPDF_BOOL; cdecl;
AddSegment = procedure(
pThis : PFX_DOWNLOADHINTS;
offset, size: size_t); cdecl;
Ak deklarujete tieto offsety ako 32-bitový typ, binding funguje na Win32 a na Delphi Win64, ale potom ticho zlyhá na FPC a Lazarus Win64. Príčina je subtílna. Na FPC Win64 je NativeUInt skutočný 64-bitový typ so šírkou ukazovateľa a size_t je naň aliasovaný. Binding obsahuje v sekcii typov komentár varujúci presne pred zatienením NativeUInt na FPC, pretože jeho redefinícia na 32-bitový alias by tam prinútila size_t mať 32 bitov a poškodila by každý parameter typu size_t odovzdávaný do knižnice alebo z nej zapisovaný. 64-bitový offset prichádzajúci do 32-bitového parametra stratí svoju hornú polovicu. Pri malom súbore sa každý offset zmestí do 32 bitov a nič nie je chybné. Pri veľkom súbore, v momente keď offset prekročí štvor-gigabajtovú hranicu, orezaná hodnota ukazuje úplne inam, PDFium sa spýta, či je k dispozícii nesprávny rozsah bajtov, a progresívne načítavanie zamrzne alebo číta odpad. Chyba je neviditeľná, kým nie je súbor dostatočne veľký a cieľom nie je systém, kde sa size_t skutočne rozšíril.
Výnimka v Pascale nesmie nikdy prebehnúť cez C rámec
Tretia trieda sa týka modelu výnimiek, ktorý C nemá. Keď PDFium zavolá jedno z vašich spätných volaní, váš pascalovský kód beží vnútri zásobníka C a C++ rámcov, ktoré nevedia nič o mechanizme výnimiek v Delphi. Ak vaše spätné volanie vyvolá výnimku a nechá ju propagovať, odvinie sa (unwind) cez rámce, ktoré nikdy neboli navrhnuté na odvinutie. Vlastné čistenie PDFium sa nespustí, jeho interné invarianty zostanú polovične aktualizované a proces sa ocitne v stave, ktorý knižnica never predpokladala. Kontraktom pre tieto spätné volania je návratový kód, nie výnimka.
Dve spätné volania to robia konkrétnym. FPDF_FILEWRITE je cieľ (sink), do ktorého PDFium zapisuje uložený dokument, a FPDF_FILEACCESS je zdroj, z ktorého číta vstupný dokument. Obe sú tu implementované nad Delphi TStream a obe môžu zlyhať spôsobom, akým zlyháva akýkoľvek stream: disk sa zaplní, stream sa pod vami zatvorí, čítanie prebehne za koniec. Spätné volanie zápisu obaluje svoj zápis do streamu a mení akékoľvek zlyhanie na chybový kód PDFium, namiesto toho, aby ho nechalo uniknúť.
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 čítania robí to isté: neúspešné čítanie vráti nulu, aby vyhovelo kontraktu FPDF_FILEACCESS, namiesto vyvolania výnimky cez hranicu. Prázdny blok except bez opätovného vyvolania (re-raise) vyzerá pre programátora v Pascale, vycvičeného nikdy neprehĺtať výnimky, nesprávne a v bežnom Pascale to aj nesprávne je. Na hranici ABI je to však správny tvar, pretože jedinou bezpečnou hodnotou na odovzdanie späť volajúcemu v C je stavový kód, ktorý vie interpretovať. Zlyhanie sa stále propaguje, ibaže prostredníctvom návratovej hodnoty, a volajúci kód nad knižnicou ho po návrate riadenia na stranu Pascalu vyzdvihne ako EPdfError.
Dvojité uvoľnenie (double free) sa skrýva v chybovej ceste
Štvrtá chyba sa týka vlastníctva. Handle dokumentu PDFium je otvorený knižnicou a musí byť zatvorený presne raz, pomocou FPDF_CloseDocument. Nebezpečenstvom je chybová cesta, ktorá uvoľní handle, ktorý vlastní aj druhá čistiaca rutina. Predstavte si rutinu, ktorá vytvorí obalový objekt, priradí mu čerstvo otvorený handle dokumentu a potom vykoná ďalšie nastavenia, ktoré môžu zlyhať. Ak toto nastavenie vyvolá výnimku, obsluha predčasného návratu, ktorá volá FPDF_CloseDocument na surovom handle, ho zatvorí a vlastný deštruktor obalového objektu ho pri uvoľnení objektu zatvorí znova. Handle je uvoľnený dvakrát, čo je nedefinované správanie a pravdepodobný pád.
Audit to zistil na ceste importu v štýle vyradenia stránok (imposition), ktorá stavia TPdf okolo už otvoreného handlu. Opravou je urobiť z prenosu vlastníctva jediný zdroj pravdy. Akonáhle je handle priradený do poľa obalu, obal ho vlastní a jediným vyčistením v chybovej ceste je uvoľnenie obalu. Deštruktor obalu volá FPDF_CloseDocument za vás, takže druhé explicitné zatvorenie by dvakrát uvoľnilo ten istý dokument. Opravený chybový handler uvoľní objekt a znova vyvolá výnimku, pričom existuje presne jedna cesta k zatvoreniu.
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 aj knižnica plná exportov potrebujú explicitné zrušenie
Posledná trieda sa týka pamäte, ktorú kompilátor spravuje vo vašom mene a ktorú zvyk z jazyka C môže potichu poškodiť. Mnohé pomocné funkcie tohto bindingu vracajú záznam (record), ktorý obsahuje WideString alebo dynamické pole. Ide o polia s počítaním referencií a kompilátor generuje skryté účtovníctvo na udržiavanie ich počtov. Inštinkt prenesený z C je vyčistiť nový záznam pomocou FillChar(Result, SizeOf(Result), 0). To zapíše nuly cez spravovanú referenciu vnútri záznamu bez toho, aby ju najprv dekrementovalo. Kompilátor znova používa jednu skrytú dočasnú premennú pre výsledok funkcie naprieč iteráciami slučky, takže pri druhej iterácii FillChar prepíše živý ukazovateľ na reťazec, ktorý nebol nikdy uvoľnený, a reťazec, na ktorý ukazoval, unikne (leak). Zavolajte funkciu v slučke nad tisíckou anotácií a unikne vám tisíc reťazcov.
Opravou je nechať jazyk vyčistiť záznam spôsobom, akým to vie, pomocou Default(T), čo uvoľní akékoľvek spravované pole pred jeho vynulovaní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);
Súvisiaci problém s vlastníctvom žije na hranici načítavania knižnice. Tento binding vyhľadáva niekoľko stoviek ukazovateľov na funkcie z PDFium DLL pomocou GetProcAddress po volaní LoadLibrary. Ak chýba jeden vyžadovaný export, čiastočne zviazaný stav je nebezpečný: desiatky ukazovateľov sú platné, zvyšok je nil alebo neplatný a akékoľvek neskoršie volanie cez jeden z nich skočí do modulu, ktorý už môže byť uvoľnený z pamäte. Binding to rieši tak, že uvoľní knižnicu a spustí kompletné ClearAllBindings, ktoré resetuje každý importovaný ukazovateľ späť na nil vždy, keď sa nepodarí nájsť požadovaný export. Potom už žiadny ukazovateľ nevisí do uvoľneného modulu a neskoršie volanie zlyhá čisto na kontrole nil ukazovateľa, namiesto vetvenia do uvoľneného kódu.
Obal (wrapper) je miesto, kde sa ručne prepisujú štyri kontrakty
Žiadna z týchto piatich chýb nie je exotická. Sú to predvídateľné režimy zlyhania tenkej pascalovskej vrstvy nad C API a zoskupujú sa preto, lebo táto vrstva je presne miestom, kde musia byť znova deklarované štyri samostatné kontrakty. Volacia konvencia musí byť označená ako cdecl pri každom spätnom volaní. Šírka celého čísla musí zodpovedať size_t na jedinom cieľovom systéme, kde sa v skutočnosti rozširuje. Model výnimiek musí byť prevedený na stavové kódy pri každom spätnom volaní, ktoré opúšťa Pascal. Vlastníctvo každého handlu a každého spravovaného poľa musí byť jasne stanovené a dodržiavané na každej ceste, vrátane chybových ciest, ktoré nikto netestuje až do produkcie. Ak vynecháte ktorýkoľvek z nich, dostanete chybu, ktorej príznak sa prejaví ďaleko od jej príčiny, čo robí túto kategóriu nákladnou. Hodnota auditu nespočívala v jedinej oprave, ale v tom, že sa ku každému z týchto bodov pristupovalo ako k vlastnej disciplíne, ktorú treba skontrolovať naprieč celým bindingom.
Ak chcete vidieť binding pri skutočnej práci a nie iba pri strážení jeho hraníc, techniky kešovania vykresľovania a priblíženia v našej poznámke o výkone kešovania a zoomu ukazujú vykresľovaciu cestu a návod na cross-kompiláciu pri tvorbe prehliadača v Lazaruse a FPC je miestom, kde na správaní size_t vo Win64 skutočne záleží. Obe témy stavajú na rovnakej práci v oblasti pamäťovej bezpečnosti a ABI, ktorá sa dodáva v PDFium Component pre Delphi, Lazarus a C++Builder spolu s API na vykresľovanie, extrakciu textu a formuláre, ktoré sú popísané inde na tomto blogu.