Technical Article

Hærdning af en PDFium VCL-binding: ABI og hukommelsessikkerhed

En Pascal-binding over et C-bibliotek læses som almindelig Pascal. Du kalder en metode, du får en record tilbage, du frigør det, du allokerede. Problemet er, at PDFium is et C- og C++-bibliotek med sin egen kaldekonvention, sine egne heltalsbredder og sine egne regler for, hvem der ejer hukommelsen, og hvem der frigør den. Intet af dette krydser sproggrænsen af sig selv. Hver eneste af disse kontrakter skal gentages manuelt i Pascal-erklæringerne, og et enkelt forkert ord forvandler et pænt kald til en stakbeskadigelse, en afkortet forskydning eller en dobbelt frigivelse. En v1.61.0-audit af en PDFium VCL-binding afslørede en fejl af hver slags. De er værd at gennemgå, fordi de ikke er specifikke for denne binding. De er de stående risici ved at pakke ethvert C-API ind i Delphi eller Lazarus.

cdecl er en del af funktionstypen, ikke en dekoration

PDFium er kompileret C. På Win32 bruger dets eksporter og, hvad vigtigere er, de callbacks, det kalder, kaldekonventionen cdecl. Under cdecl rydder kalderen stakken op, efter at kaldet returnerer. Delphis indbyggede standard er register, og Win32 C-standarden for callbacks er stdcall i nogle biblioteker, hvor den kaldte rydder op i stedet. Når en struktur giver PDFium en funktionspeger, og du glemmer cdecl på denne pegers type, er de to sider uenige om, hvem der justerer stakpegeren. Begge retter det, eller ingen gør, og stakpegeren skrider med argumenternes størrelse ved hvert kald.

Grunden til, at denne fejl er svær at finde, er, at skaden ikke er lokal. Det beskadigede kald returnerer og ser fint ud. Forskydningen viser sig senere i en helt urelateret funktion, hvis ramme nu sidder på en stakpeger, der er et par bytes ved siden af, og det manifesterer sig som en vilkårlig læsning, en dårlig returadresse eller et crash med en backtrace, der peger langt væk fra det callback, du faktisk tog fejl af. Formularudfyldning (form-fill) er det klassiske sted, dette bider, fordi formularudfyldnings-interfacet er en record fuld af callbacks, som PDFium kalder tilbage til. En af dem, FFI_OpenFile, giver PDFium en funktion, den vil kalde for at åbne en ekstern fil, erklæret som function(pThis: PFPDF_FORMFILLINFO; fileFlag: Integer; wsURL: FPDF_WIDESTRING; mode: PAnsiChar): PFPDF_FILEHANDLER; cdecl. Den afsluttende cdecl er det punkt, der er værd at kopiere. Undlad det, og koden compilerer stadig, linker stadig og kører stadig, lige indtil PDFium kalder funktionen. Konventionen tilhører selve funktionstypen. Det er ikke valgfrit sukker, og compileren vil ikke advare dig, når det mangler, fordi en almindelig funktionstype er en fuldt ud lovlig Pascal-type. Det eneste forsvar er at behandle kaldekonventionen som et obligatorisk felt i enhver importeret signatur og ethvert callback, du sender udad.

size_t er pegerbredde, og på FPC Win64 betyder det 64 bits

Den anden fejl er et mismatch i heltalsbredden, som kun optræder på ét mål. C's size_t er defineret til at være bred nok til at indeholde enhver objektstørrelse, hvilket på en 64-bit platform betyder et 64-bit heltal uden fortegn. PDFiums progressive indlæsningsgrænseflader taler i size_t byte-forskydninger. Tilgængelighedsudbyderens FX_FILEAVAIL-record bærer et IsDataAvail-callback, som PDFium kalder med en forskydning og en størrelse, og FX_DOWNLOADHINTS-recordens AddSegment-callback modtager det samme. Begge parametre er size_t.

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

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

Hvis du erklærer disse forskydninger som en 32-bit type, fungerer bindingen på Win32 og på Delphi Win64, men fejler derefter lydløst på FPC og Lazarus Win64. Årsagen er subtil. På FPC Win64 er NativeUInt en ægte peger-bredde 64-bit type, og size_t er aliaseret til den. Bindingen har en kommentar i type-sektionen, der netop advarer mod at skygge for NativeUInt på FPC, fordi en redefinering af den til et 32-bit alias der ville tvinge size_t til 32 bits og beskadige enhver size_t-parameter, der sendes til eller skrives af biblioteket. En 64-bit forskydning, der ankommer til en 32-bit parameter, mister sin øverste halvdel. For en lille fil passer hver forskydning i 32 bits, og intet er galt. For en stor fil, i det øjeblik en forskydning krydser fire-gigabyte-grænsen, peger den afkortede værdi et helt andet sted hen, PDFium spørger, om det forkerte byte-område er tilgængeligt, og progressiv indlæsning går i stå eller læser skrammel. Fejlen er usynlig, indtil filen er stor nok, og målet er det, hvor size_t rent faktisk udvidede sig.

En Pascal-undtagelse må aldrig afvikles gennem en C-ramme

Den tredje klasse handler om undtagelsesmodellen, som C ikke har. Når PDFium kalder et af dine callbacks, din Pascal-kode kører i en stak af C- og C++-rammer, der intet ved om Delphis undtagelsesmaskineri. Hvis dit callback kaster en fejl og lader undtagelsen forplante sig, afvikler den gennem rammer, der aldrig er bygget til at blive afviklet. PDFiums egen oprydning kører ikke, dens interne invarianter efterlades halvt opdaterede, og processen er nu i en tilstand, som biblioteket aldrig havde forudset. Kontrakten for disse callbacks er en returkode, ikke en undtagelse.

To callbacks gør dette konkret. FPDF_FILEWRITE er modtageren, PDFium skriver et gemt dokument til, og FPDF_FILEACCESS er kilden, den læser et inputdokument fra. Begge er implementeret her over en Delphi TStream, og begge kan fejle på samme måde, som enhver strøm fejler: disken bliver fuld, strømmen lukkes under dig, en læsning løber forbi slutningen. Skrive-callbacket pakker sit strømskriv ind og gør ethvert svigt til PDFiums fejlkode i stedet for at lade det undslippe.

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;

Læsesiden gør det samme: En mislykket læsning rapporterer nul for at matche FPDF_FILEACCESS-kontrakten i stedet for at kaste en fejl over grænsen. En bar except uden genkastning ser forkert ud for en Pascal-programmør, der er uddannet til aldrig at opsluge undtagelser, og i almindelig Pascal er det forkert. Ved en ABI-grænse er det den korrekte form, fordi den eneste sikre værdi at give tilbage to C-kalderen er en statuskode, som den forstår at tolke. Svigtet forplanter sig stadig, blot gennem returværdien, og den kaldende kode over biblioteket præsenterer det som EPdfError, når kontrollen er tilbage på Pascals side af hegnet.

Dobbelt frigivelse gemmer sig på fejlstien

Den fjerde fejl er ejerskab. Et PDFium-dokumenthåndtag åbnes af biblioteket og skal lukkes præcis én gang med FPDF_CloseDocument. Faren er en fejlsti, der frigør et håndtag, som en anden oprydning også ejer. Forestil dig en rutine, der opretter et wrapper-objekt, tildeler et nyåbnet dokumenthåndtag til det og derefter foretager yderligere opsætning, der kan fejle. Hvis opsætningen kaster en fejl, vil en tidlig retur-handler, der kalder FPDF_CloseDocument på det rå håndtag, lukke det, og derefter vil wrapper-objektets egen destruktor lukke det igen, når objektet frigøres. Håndtaget frigøres to gange, hvilket er udefineret adfærd og et sandsynligt crash.

Revisionen fandt dette på en impositions-lignende importsti, der bygger en TPdf omkring et allerede åbent håndtag. Løsningen er at gøre ejerskabsoverdragelse til den eneste kilde til sandhed. Når først håndtaget er tildelt wrapperens felt, ejer wrapperen det, og den eneste oprydning på fejlstien er at frigøre wrapperen. Wrapperens destruktor kalder FPDF_CloseDocument for dig, så en anden eksplicit lukning ville dobbelt-frigøre det samme dokument. Den korrigerede fejl-handler frigør objektet og genkaster fejlen, og der er præcis én sti til lukningen.

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;

Administrerede records og et bibliotek fuldt af eksporter har begge brug for eksplicit nedlukning

Den sidste klasse handler om hukommelse, som compileren administrerer på dine vegne, og som en C-vane lydløst vil beskadige. Mange af denne bindings hjælpefunktioner returnerer en record, der indeholder en WideString eller et dynamisk array. Det er referencetalte felter, og compileren afgiver skjulte bogføring for at vedligeholde deres tal. Instinktet overført fra C er at rydde en ny record med FillChar(Result, SizeOf(Result), 0). Det stempler nuller over den administrerede reference inde i recorden uden at dekrementere den først. Compileren genbruger en skjult midlertidig variabel til et funktionsresultat på tværs af løkke-iterationer, så ved den anden iteration overskriver FillChar en levende strengpeger, der aldrig blev frigivet, og strengen, den pegede på, lækker. Kald funktionen i en løkke over tusind annoteringer, og du lækker tusind strenge.

Løsningen er at lade sproget rydde recorden på den måde, det forstår, med Default(T), hvilket frigiver ethvert administreret felt, før det nulstilles.

// 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);

Et relateret ejerskabsproblem findes ved biblioteks-indlæsningsgrænsen. Denne binding finder flere hundrede funktionspegere ud af PDFium DLL'en med GetProcAddress efter en LoadLibrary. Hvis en påkrævet eksport mangler, den delvist bundne tilstand er farlig: snesevis af pegere er gyldige, resten er nil eller forældede, og ethvert senere kald gennem en af dem hopper ind i et modul, der måske allerede er indlæst. Bindingen håndterer dette ved at afindlæse biblioteket og køre en fuld ClearAllBindings, der nulstiller enhver importeret peger tilbage til nil, når en påkrævet eksport ikke kan findes. Derefter dingler ingen funktionspeger ind i et afindlæst modul, og et senere kald fejler rent med en nil-peger-kontrol i stedet for at forgrene sig ind i frigivet kode.

Wrapperen er der, hvor fire kontrakter gentages manuelt

Ingen af disse fem fejl er eksotiske. De er de forudsigelige fejltilstande for et tyndt Pascal-lag over et C-API, og de hober sig op, fordi dette lag er præcis der, hvor fire separate kontrakter skal re-deklareres. Kaldekonventionen skal staves cdecl på hvert callback. Heltalsbredden skal matche size_t på det ene mål, hvor den rent faktisk udvidede sig. Undtagelsesmodellen skal konverteres til returkoder ved hvert callback, der krydser ud af Pascal. Ejerskabet af hvert håndtag og hvert administreret felt skal fastlægges én gang og overholdes på enhver sti, inklusive de fejlstier, som ingen tester før produktion. Gå glip af en af dem, og du får en fejl, hvis symptom viser sig langt fra dens årsag, hvilket er det, der gør denne kategori dyr. Revisionens værdi lå mindre i nogen enkelt rettelse end i at behandle hver af disse som sin egen disciplin til kontrol på tværs af hele bindingen.

Hvis du vil se bindingen udføre reelt arbejde i stedet for at beskytte sine kanter, viser render-cache- og zoomteknikkerne i vores note om render-cache og zoom-ydeevne renderingsstien, og gennemgangen af cross-compileren i opbygning af en Lazarus- og FPC-fremviser er det sted, hvor Win64 size_t-adfærden beskrevet her rent faktisk gør en forskel. Begge bygger på det samme hukommelsessikkerheds- og ABI-arbejde, som leveres i PDFium Component til Delphi, Lazarus og C++Builder sammen med API'erne til rendering, tekstekstraktion og formularer, der er dækket andre steder på denne blog.