Articol tehnic

Consolidarea unei legături PDFium: ABI și memorie

O legătură Pascal peste o bibliotecă C se citește ca Pascal obișnuit. Apelați o metodă, primiți înapoi un record, eliberați ce ați alocat. Necazul este că PDFium este o bibliotecă C și C++ cu propria convenție de apel, propriile lățimi de întregi și propriile reguli despre cine deține memoria și cine o eliberează. Nimic din toate acestea nu trece de la sine granița dintre limbaje. Fiecare dintre aceste contracte trebuie reafirmat de mână în declarațiile Pascal, iar un singur cuvânt greșit transformă un apel cu aspect curat într-o corupere de stivă, un offset trunchiat sau o dublă eliberare. Un audit al versiunii v1.61.0 a unei legături PDFium Component a scos la iveală câte un defect din fiecare categorie. Merită parcurse pentru că nu sunt specifice acestei legături. Sunt pericolele permanente ale împachetării oricărui API C în Delphi sau Lazarus

cdecl face parte din tipul funcției, nu este o podoabă

PDFium este C compilat. Pe Win32, exporturile lui și, mai important, callback-urile pe care le invocă folosesc convenția de apel cdecl. Sub cdecl, apelantul curăță stiva după ce apelul returnează. Valoarea implicită nativă a lui Delphi este register, iar standardul C de pe Win32 pentru callback-uri este stdcall în unele biblioteci, unde curăță în schimb apelatul. Când o structură îi dă lui PDFium un pointer de funcție și uitați cdecl pe tipul acelui pointer, cele două părți nu cad de acord cine ajustează indicatorul de stivă. Fie îl ajustează amândouă, fie niciuna, iar indicatorul de stivă se deplasează cu dimensiunea argumentelor la fiecare invocare

Motivul pentru care acest defect este greu de găsit este că paguba nu este locală. Apelul corupt returnează și arată în regulă. Nealinierea apare mai târziu, într-o funcție fără legătură al cărei cadru stă acum pe un indicator de stivă deplasat cu câțiva octeți, și se manifestă ca o citire sălbatică, o adresă de retur greșită sau o prăbușire cu o urmă care nu arată nicăieri lângă callback-ul pe care l-ați greșit de fapt. Completarea formularelor este locul clasic unde asta mușcă, pentru că interfața de completare este un record plin de callback-uri pe care PDFium le apelează înapoi. Unul dintre ele, FFI_OpenFile, îi dă lui PDFium o funcție pe care o va apela pentru a deschide un fișier extern, declarată ca function(pThis: PFPDF_FORMFILLINFO; fileFlag: Integer; wsURL: FPDF_WIDESTRING; mode: PAnsiChar): PFPDF_FILEHANDLER; cdecl. Acel cdecl de la final este partea care merită copiată. Îl scoateți și codul tot compilează, tot se leagă și tot rulează, până în clipa în care PDFium apelează funcția. Convenția aparține tipului funcției însuși. Nu este zahăr opțional, iar compilatorul nu vă va avertiza când lipsește, pentru că un tip simplu de funcție este un tip Pascal perfect legal. Singura apărare este să tratați convenția de apel ca pe un câmp obligatoriu al fiecărei semnături importate și al fiecărui callback pe care îl trimiteți în afară

Diagramă care pune în contrast un callback cdecl care menține echilibrată stiva deținută de apelantul PDFium cu un cdecl lipsă, care deplasează indicatorul de stivă până la o prăbușire îndepărtată
cdecl aparține tipului funcției, iar scoaterea lui deplasează indicatorul de stivă până când se prăbușește vreun cadru fără legătură

size_t are lățimea unui pointer, iar pe FPC Win64 asta înseamnă 64 de biți

Al doilea defect este o nepotrivire de lățime de întreg care apare doar pe o singură țintă. size_t din C este definit ca fiind destul de larg cât să cuprindă orice dimensiune de obiect, ceea ce pe o platformă pe 64 de biți înseamnă un întreg fără semn pe 64 de biți. Interfețele de încărcare progresivă ale lui PDFium vorbesc în offseturi de octeți de tip size_t. Recordul FX_FILEAVAIL al furnizorului de disponibilitate poartă un callback IsDataAvail pe care PDFium îl apelează cu un offset și o dimensiune, iar callback-ul AddSegment al recordului FX_DOWNLOADHINTS primește la fel. Ambii parametri sunt size_t

IsDataAvail = function(
  pThis       : PFX_FILEAVAIL;
  offset, size: size_t): FPDF_BOOL; cdecl;

AddSegment = procedure(
  pThis       : PFX_DOWNLOADHINTS;
  offset, size: size_t); cdecl;

Dacă declarați acele offseturi ca tip pe 32 de biți, legătura funcționează pe Win32 și pe Delphi Win64, apoi se strică în tăcere pe FPC și Lazarus Win64. Cauza este subtilă. Pe FPC Win64, NativeUInt este un tip veritabil pe 64 de biți, cu lățimea unui pointer, iar size_t este aliasat la el. Legătura are un comentariu în secțiunea de tipuri care avertizează exact împotriva umbririi lui NativeUInt pe FPC, pentru că redefinirea lui acolo ca alias pe 32 de biți ar forța size_t la 32 de biți și ar corupe fiecare parametru size_t transmis către bibliotecă sau scris de ea. Un offset pe 64 de biți care ajunge la un parametru pe 32 de biți își pierde jumătatea de sus. Pentru un fișier mic, orice offset încape în 32 de biți și nimic nu este greșit. Pentru un fișier mare, în clipa în care un offset trece linia de patru gigaocteți, valoarea trunchiată arată complet în altă parte, PDFium întreabă dacă este disponibil intervalul greșit de octeți, iar încărcarea progresivă se blochează sau citește gunoi. Defectul este invizibil până când fișierul este destul de mare și ținta este cea pe care size_t chiar s-a lățit

Diagramă PDFium Component a unui offset size_t de cinci gigaocteți care supraviețuiește ca valoare completă pe 64 de biți, dar se rotește la aproximativ un gigaoctet când un alias pe 32 de biți micșorează parametrul pe FPC Win64
Un parametru size_t forțat la 32 de biți trunchiază offseturile de peste patru gigaocteți și trimite încărcarea progresivă spre intervalul greșit de octeți

O excepție Pascal nu trebuie niciodată să se desfășoare printr-un cadru C

A treia categorie ține de modelul de excepții, pe care C nu îl are. Când PDFium apelează unul dintre callback-urile dumneavoastră, codul Pascal rulează în interiorul unui teanc de cadre C și C++ care nu știu nimic despre mecanica de excepții a lui Delphi. Dacă acel callback ridică o excepție și o lasă să se propage, ea se desfășoară prin cadre care nu au fost niciodată construite pentru așa ceva. Curățarea proprie a lui PDFium nu rulează, invarianții lui interni rămân actualizați pe jumătate, iar procesul se află acum într-o stare pe care biblioteca nu a anticipat-o niciodată. Contractul acestor callback-uri este un cod de retur, nu o excepție

Două callback-uri fac lucrul concret. FPDF_FILEWRITE este colectorul în care PDFium scrie un document salvat, iar FPDF_FILEACCESS este sursa din care citește un document de intrare. Amândouă sunt implementate aici peste un TStream Delphi și amândouă pot eșua așa cum eșuează orice flux: discul se umple, fluxul este închis pe dedesubt, o citire trece de capăt. Callback-ul de scriere își încadrează scrierea în flux și transformă orice eșec în codul de eșec al lui PDFium, în loc să îl lase să scape

function WriteBlock(
  pThis: PFPDF_FILEWRITE;
  pData: Pointer;
  Size : LongWord): Integer; cdecl;
begin
  // PDFium tratează orice retur diferit de 1 ca eșec de scriere. O excepție
  // Pascal nu trebuie să se desfășoare prin acest cadru cdecl/C++, așa că
  // o prindem și raportăm eșec în schimb.
  Result := 0;
  try
    PPdfWrite(pThis).Stream.WriteBuffer(pData^, Size);
    Result := 1;
  except
  end;
end;

Partea de citire face la fel: o citire eșuată raportează zero, ca să respecte contractul FPDF_FILEACCESS, în loc să ridice o excepție peste graniță. Un except gol, fără reridicare, îi pare greșit unui programator Pascal învățat să nu înghită niciodată excepțiile, iar în Pascal obișnuit chiar este greșit. La o graniță ABI este forma corectă, pentru că singura valoare sigură de returnat apelantului C este un cod de stare pe care el știe să îl interpreteze. Eșecul se propagă în continuare, doar că prin valoarea de retur, iar codul apelant de deasupra bibliotecii îl scoate la suprafață ca EPdfError odată ce controlul a revenit pe partea Pascal a gardului

Dubla eliberare se ascunde pe calea de eroare

Al patrulea defect ține de proprietate. Un handle de document PDFium este deschis de bibliotecă și trebuie închis exact o dată, prin FPDF_CloseDocument. Pericolul este o cale de eroare care eliberează un handle deținut și de o a doua curățare. Imaginați-vă o rutină care creează un obiect înveliș, îi atribuie un handle de document proaspăt deschis și apoi mai face pregătiri care ar putea eșua. Dacă pregătirea aruncă o excepție, un handler cu retur timpuriu care apelează FPDF_CloseDocument pe handle-ul brut îl va închide, iar apoi destructorul propriu al obiectului înveliș îl va închide din nou când obiectul este eliberat. Handle-ul este eliberat de două ori, ceea ce înseamnă comportament nedefinit și, probabil, o prăbușire

Auditul a găsit asta pe un drum de import de tip impunere, care construiește un TPdf în jurul unui handle deja deschis. Remediul este să faceți din transferul de proprietate singura sursă de adevăr. Odată ce handle-ul este atribuit câmpului învelișului, învelișul îl deține, iar singura curățare de pe calea de eroare este eliberarea învelișului. Destructorul învelișului apelează FPDF_CloseDocument în locul dumneavoastră, așa că o a doua închidere explicită ar elibera de două ori același document. Handlerul de eroare corectat eliberează obiectul și reridică excepția, iar spre închidere există exact un singur drum

Diagramă a unei căi de eroare defecte care închide de două ori un handle de document PDFium, alături de remediul cu un singur proprietar, în care eliberarea învelișului închide handle-ul exact o dată
Transferul handle-ului în înveliș face din destructorul lui singurul drum spre FPDF_CloseDocument
Result := TPdf.Create(nil);
try
  Result.FDocument := NewDoc;   // Result deține acum handle-ul
  Result.InitializeFormFill;
  Result.ReloadPage;
except
  // Result.Free închide handle-ul. Un al doilea FPDF_CloseDocument(NewDoc)
  // aici ar elibera de două ori același document PDFium.
  Result.Free;
  raise;
end;

Recordurile gestionate și o bibliotecă plină de exporturi au nevoie amândouă de demontare explicită

Ultima categorie ține de memoria pe care compilatorul o gestionează în locul dumneavoastră și pe care un obicei de C o corupe în tăcere. Multe dintre funcțiile ajutătoare ale acestei legături returnează un record care conține un WideString sau un tablou dinamic. Acelea sunt câmpuri numărate prin referință, iar compilatorul emite evidență ascunsă ca să le mențină numărătoarea. Instinctul preluat din C este să curățați un record proaspăt cu FillChar(Result, SizeOf(Result), 0). Asta ștampilează zerouri peste referința gestionată din record fără să o decrementeze mai întâi. Compilatorul reutilizează o singură variabilă temporară ascunsă pentru rezultatul funcției de-a lungul iterațiilor buclei, așa că la a doua iterație FillChar suprascrie un pointer de șir viu, care nu a fost niciodată eliberat, iar șirul spre care arăta se pierde. Apelați funcția într-o buclă peste o mie de adnotări și pierdeți o mie de șiruri

Remediul este să lăsați limbajul să curețe recordul așa cum știe el, cu Default(T), care eliberează orice câmp gestionat înainte de a-l zeroiza

// Default() în loc de FillChar: compilatorul reutilizează o singură variabilă
// temporară ascunsă pentru rezultatul funcției de-a lungul iterațiilor, așa că
// FillChar ar zeroiza pointeri WideString vii fără să îi elibereze.
Result := Default(TPdfAnnotation);

O problemă înrudită de proprietate trăiește la granița de încărcare a bibliotecii. Această legătură rezolvă câteva sute de pointeri de funcție din DLL-ul PDFium cu GetProcAddress, după un LoadLibrary. Dacă lipsește un export necesar, starea legată parțial este periculoasă: zeci de pointeri sunt valizi, restul sunt nil sau învechiți, iar orice apel ulterior prin unul dintre ei sare într-un modul care poate fi deja descărcat. Legătura tratează asta descărcând biblioteca și rulând un ClearAllBindings complet, care readuce fiecare pointer importat la nil ori de câte ori un export necesar nu se rezolvă. După aceea niciun pointer de funcție nu mai atârnă într-un modul descărcat, iar un apel ulterior eșuează curat, printr-o verificare de pointer nul, în loc să se ramifice în cod eliberat

Învelișul este locul unde patru contracte sunt reafirmate de mână

Niciunul dintre aceste cinci defecte nu este exotic. Sunt modurile de eșec previzibile ale unui strat Pascal subțire peste un API C și se adună pentru că acel strat este exact locul unde patru contracte separate trebuie redeclarate. Convenția de apel trebuie scrisă cdecl pe fiecare callback. Lățimea întregului trebuie să se potrivească cu size_t pe singura țintă pe care el chiar se lățește. Modelul de excepții trebuie convertit în coduri de retur la fiecare callback care iese din Pascal. Proprietatea fiecărui handle și a fiecărui câmp gestionat trebuie declarată o dată și respectată pe fiecare drum, inclusiv pe căile de eroare pe care nu le exercită nimeni până în producție. Ratați una singură și obțineți un defect al cărui simptom apare departe de cauza lui, iar asta face categoria aceasta costisitoare. Valoarea auditului a stat mai puțin în vreun remediu anume și mai mult în tratarea fiecăreia dintre acestea ca disciplină proprie, de verificat pe toată legătura

Dacă vreți să vedeți legătura făcând treabă adevărată, nu doar păzindu-și marginile, tehnicile de cache de randare și de zoom din nota noastră despre performanța cache-ului de randare și a zoomului arată drumul de randare, iar parcurgerea între compilatoare din construirea unui vizualizator Lazarus și FPC este locul în care comportamentul size_t pe Win64 descris aici chiar contează. Amândouă se sprijină pe aceeași muncă de siguranță a memoriei și de ABI care se livrează în PDFium Component pentru Delphi, Lazarus și C++Builder, alături de API-urile de randare, de extragere a textului și de formulare tratate în alte părți ale acestui blog