Tehnički članak

Delphi naspram FPC: 4 skrivene zamke u kodu za PDF u PDFium projektima

Isti Object Pascal izvorni kod može se različito ponašati pod Delphijem i FPC/Lazarusom na četiri načina koji stalno pogađaju kod PDFium komponente: FPC odbacuje privremene zapise rezultata funkcije prije nego što in test pripadnosti završi s njihovim čitanjem, dcc32 dolazi s isključenom provjerom raspona pa indeksi niza izvan granica tiho čitaju smeće iz memorije, samo Delphi 13 prihvaća dodjeljivanje anonimnog array of Byte varijabli TBytes bez eksplicitnog pretvaranja (cast), a Delphi-jevo spajanje AnsiStringova može uništiti bajtove na vrijednosti $80 ili iznad nje kroz skriveni povratni krug kodne stranice (code-page round-trip). Svaki od njih stvara testni skup koji je zelen na jednom prevoditelju, a crven, ili što je još gore, tiho pogrešan na drugom

Ako prvi put postavljate projekt s dvostrukim prevoditeljem, pregled Lazarus i FPC vizualizatora pokriva lakši put: pakete, staze pretraživanja i postavljanje prozora za renderiranje na zaslon. Ovaj je članak suprotnost vodiču. To je popis problema na koje smo naišli nakon što je taj lakši put proradio, kada je CI bio zelen pod FPC-om, zelen pod Delphijem, a onda je promjena koja je prošla na jednoj strani detonirala na drugoj. Svaka zamka u nastavku dolazi iz stvarnog neuspjeha u testnom skupu PDFiumPas ili njegovim demo projektima, s forenzikom na razini commita sažetom u minimalnu reprodukciju, temeljni uzrok i popravak koji smo standardizirali

Zašto se skup pod FPC-om učitava kao prazan, ali ne i u Delphiju?

Verzija u jednoj rečenici: FPC može finalizirati privremenu varijablu koja drži rezultat zapisa funkcije prije nego što izraz koji čita polje tog rezultata završi, pa X in Func().Issues može testirati pripadnost u odnosu na već oslobođeni skup, dok ekvivalentni Delphijev izraz radi. Naši testovi sukladnosti PDF/E naišli su na to u svojoj prvoj verziji. Validator vraća zapis čije je polje Issues skup zastavica kršenja, a tvrdnje (assertions) su sadržavale ugniježđeni poziv

Dijagram koji prikazuje da Delphi drži zapis rezultata funkcije PDFium privremeno živim dok naredba ne završi dok ga FPC oslobađa prije nego in-operator pročita Issues skup, pa tvrdnja prolazi samo pod Delphijem
Delphi privremeni zapis rezultata funkcije drži živim dok naredba ne završi, dok ga FPC može osloboditi prije nego in-operator Issues skup pročita
// Nepouzdano pod FPC-om: privremeni zapis rezultata funkcije
// može biti oslobođen prije nego test 'in' pročita Issues
AssertTrue(pveiLzwUsed in ValidateAnsi(Pdf).Issues);

// Pouzdano na oba prevoditelja: prvo prikvači rezultat na lokalnu varijablu
var
  Vr: TPdfEValidationResult;
begin
  Vr := ValidateAnsi(Pdf);
  AssertTrue(pveiLzwUsed in Vr.Issues);
end;

Ugniježđeni oblik čitao je skup kao prazan pod FPC-om, pa je svaka tvrdnja koja je očekivala zastavicu zakazala, dok je identična Delphijeva inačica prošla. Temeljni uzrok je razlika u tome kako dva prevoditelju upravljaju životnim vijekom privremenih rezultata funkcija unutar većih izraza: Delphi održava privremeni rezultat na životu do kraja naredbe, dok FPC-ovo odbacivanje privremenog zapisa može prestići operator pripadnosti skupu koji ga još uvijek čita. Već smo jednom dokumentirali isto ponašanje u komentaru na pomoćnu funkciju FlagPresent u testnoj jedinici PDF/A, a zatim smo ponovno uveli istu pogrešku pišući nove testne ispočetka, što vam govori koliko prirodno izgleda taj neispravni oblik. Ispravak je mehanički i vrijedi ga usvojiti kao opće pravilo: nikada ne povezujte pristup polju ili test skupa izravno na poziv funkcije koja vraća zapis; najprije dodijelite rezultat lokalnoj varijabli, a zatim pročitajte polje. To košta jedan redak i uklanja cijelu klasu nestabilnosti ovisnih o prevoditelju

Zašto Delphi prihvaća indeks niza koji FPC odbija kompajlirati?

Verzija u jednoj rečenici: dcc32 kompajlira indeks izvan raspona u niz s fiksnim granicama i, s onemogućenom zadanom provjerom raspona, čita ili piše u susjednu memoriju tijekom izvršavanja bez ikakve pogreške, dok FPC odbija isti indeks u vrijeme kompajliranja. PDFium komponenta deklarira točke četverokuta kao 1-bazirani niz, TQuadrilateralPoint = array [1..4] of TPdfPoint, što odgovara uobičajenom numeriranju QuadPoints unosa u PDF-u. Demo koji ga je punio refleksivnom 0-baziranom petljom radio je mjesecima pod Delphijem

PDFium Component dijagram PDF polja quad-točaka indeksiranog od 1 gdje indeks 0 pod dcc32 tiho dodiruje susjedno polje zapisa dok FPC zaustavlja istu petlju s greškom provjere raspona pri kompajliranju
S isključenom provjerom raspona u dcc32, indeks 0 utiho slijeće na susjedno polje zapisa, dok FPC istu petlju u vrijeme kompajliranja odbija
var
  I: Integer;
begin
  for I := 0 to 3 do                       // pogrešno: niz je [1..4]
    Data.AttachmentPoints[I] := Corner[I]; // dcc32 zadano: kompajlira se, indeks 0
                                           // tiho dira susjednu memoriju
                                           // FPC: pogreška provjere raspona u vrijeme kompajliranja
  for I := Low(TQuadrilateralPoint) to High(TQuadrilateralPoint) do
    Data.AttachmentPoints[I] := Corner[I - 1];  // ispravno na oba prevoditelja
end;

Delphijeva kompilacija bila je lažno pozitivna: s isključenom provjerom raspona, što je zadana postavka za dcc32, indeks 0 je sletio na bilo koje polje koje prethodi nizu u zapisu, a demo je naizgled radio. Portiranje istog demo projekta na Lazarus proizvelo je trenutnu pogrešku provjere raspona u vrijeme kompajliranja iz FPC-a, a ispravljanje indeksa potom je otkrilo drugu, dublju pogrešku u stazi bilješki knjižnice koju su tiha čitanja smeća maskirala, onu analiziranu u članku o označavanju QuadPoints-a. Iz tog su incidenta proizašle dvije lekcije. Prvo, dajte prednost funkcijama Low() i High() nad doslovnim granicama kad god tip niza nije po konstrukciji 0-baziran. Drugo, tretirajte FPC kompilaciju, ili barem jednu Delphijevu s omogućenim {$R+}, kao obaveznu provjeru za svaki novi demo ili test: zadane postavke dcc32 neće vas obavijestiti o ovoj klasi pogrešaka, a program koji radi nije dokaz da je ispravan

Dodjela TBytes koju prihvaća samo Delphi 13

Verzija u jednoj rečenici: dodjeljivanje polja deklariranog kao anonimni array of Byte varijabli TBytes kompajlira se na Delphiju 13 (verzija prevoditelja 37.0), ali ne uspijeva na Delphiju 12 Athens i svim ranijim izdanjima uz pogrešku E2010 Incompatible types: 'TArray<Byte>' and 'Dynamic array'. Ovo nije toliko podjela Delphi-naspram-FPC-a, koliko podjela Delphi-naspram-svoje-vlastite-prošlosti, ali pogađa istu bazu koda s više prevoditelja na isti način: najnoviji prevoditelj tiho prihvaća konstrukciju koju sve ostalo odbija

type
  TValidator = class
  private
    FBuffer: array of Byte;   // anonimni tip dinamičkog niza
  end;

var
  OrigBytes: TBytes;
begin
  OrigBytes := FBuffer;          // samo Delphi 13; E2010 na Delphiju 12
                                 // Athens i starijim
  OrigBytes := TBytes(FBuffer);  // kompajlira se svugdje; isti raspored bajtova,
                                 // sigurno čvrsto pretvaranje tipa
end;

Isporučili smo točno to u validacijskoj rutini, razvijenoj i testiranoj lokalno na Delphiju 13, gdje je implicitna pretvorba bila tiho prihvaćena. Instalacijski program s cjelovitim izvornim kodom služi velikom broju korisnika na Delphiju 12 i starijim verzijama, a za njih se jedinica jednostavno nije kompajlirala. Strukturno rješenje je ili čvrsto pretvaranje tipa (hard cast) prikazano gore, što je sigurno jer anonimni array of Byte i TBytes dijele identičan raspored dinamičkog niza, ili još bolje, deklariranje polja kao imenovanog tipa poput TBytes od samog početka kako do pretvorbe uopće ne bi dolazelo. Popravak procesa je još važniji: konstrukcija koja se kompajlira na vašem najnovijem lancu alata ne dokazuje ništa o starijim prevoditeljima koje vaši korisnici stvarno pokreću, i ova je kategorija regresije nevidljiva sve dok ne pokrenete izgradnju protiv svih podržanih verzija. Naše skripte za izdanja sada kompajliraju knjižnicu kroz cijelu matricu prevoditelja upravo zato što lokalna 37.0 kompilacija ne može uhvatiti popustljivost specifičnu za Delphi 13

Bajt AnsiStringa koji nestaje na kineskom sustavu Windows

Verzija u jednoj rečenici: spajanje sirovog bajta na vrijednosti $80 ili više u AnsiString pomoću operatora + može tiho zamijeniti taj bajt znakom ? ($3F) pod Delphijem, jer izraz prolazi kroz implicitni povratni krug iz AnsiStringa u UnicodeString i natrag u AnsiString kroz sistemsku kodnu stranicu. To smo otkrili kroz PDF/A test koji konstruira naziv koji sadrži izolirani bajt $FE, koji nikada nije ispravan UTF-8 vodeći bajt, kako bi provjerio označava li validator nazive koji nisu valjani UTF-8 prema ISO 19005-2 klauzuli 6.1.8

Dijagram AnsiString u UnicodeString povratnog puta koji zamjenjuje sirovi $FE bajt upitnikom na kineskom Windows stroju s CP936, uz sigurnu zakrpu bajta na mjestu u Delphiju
Implicitni UnicodeString povratni put nemapirajući $FE bajt zamjenjuje s $3F pod CP936, pa sigurna putanja bajt na mjestu zakrpi
var
  BadName: AnsiString;
begin
  // Na Delphiju s višebajtnom sistemskom kodnom stranicom (uočeno na CP936),
  // spajanje prolazi povratni krug kroz UnicodeString, a $FE, koji
  // nije valjan CP936 slijed, vraća se kao '?' ($3F)
  BadName := '/Bad' + AnsiChar($FE) + 'Name';

  // Sigurno: izgradi s ASCII rezervnim mjestom, zatim zakrpaj bajt na licu mjesta;
  // indeksirana dodjela u smirenom AnsiStringu ne prolazi povratni krug
  BadName := '/Bad' + #1 + 'Name';
  BadName[5] := AnsiChar($FE);
end;

Na kineskom Windows sustavu koji koristi kodnu stranicu 936, spojeni niz uopće nije sadržavao $FE, pa knjižnica opravdano nije prijavila ništa i test je propao, što je izgledalo kao pogreška knjižnice. Knjižnica zapravo nikada nije bila u krivu: FPC okruženje koje je ubacilo PDF koji stvarno sadrži bajt $FE dobilo je očekivanu zastavicu. Korupcija se dogodila unutar Delphijeve testne izvršne datoteke tijekom procjene izraza niza, jer Delphijev Unicode-prvi model znakovnih nizova pretvara mješovite AnsiString izraze kroz UnicodeString, a $FE nije valjan vodeći bajt u CP936 pa ga povratno putovanje zamjenjuje. Budimo pošteni prema granicama: na jednobajtnoj zapadnoj kodnoj stranici kao što je CP1252 isti izraz obično preživljava, što je upravo razlog zašto se ovaj bug skriva na većini razvojnih strojeva i pojavljuje se samo na istočnoazijskim sustavima ili lokaliziranim CI poslužiteljima. Pravilo koje smo usvojili: nikada ne gradite binarne testne vektore koji sadrže bajtove na $80 ili više spajanjem AnsiStringova; ili zakrpajte bajtove na licu mjesta nakon što se niz smiri, kao gore, ili od početka konstruirajte vektor u tipu TBytes

Što bi radni tijek s dvostrukim prevoditeljem preradio prema zadanim postavkama

Četiri zamke, jedan obrazac: svaki prevoditelj vas obavještava o različitom podskupu vaših pogrešaka. FPC-ova analiza raspona u vrijeme kompajliranja uhvatila je indeks izvan granica koji je dcc32 tiho izvršavao mjesecima, a dcc32-ov Unicode model znakovnih nizova otkrio je ovisnost o kodnoj stranici koju čisti bajt-orijentirani FPC build nikada ne pokreće. Praktična posljedica je da nijedan samostalan zeleni cjevovod nije dovoljan. Unakrsno kompajliranje (cross-compiling) nije samo kvačica za portabilnost, to je drugi statički analizator i drugi runtime model primijenjen na isti izvor, u istom duhu kao i obrambene provjere granica u članku o očvršćivanju ABI-ja i sigurnosti memorije

Pravila koja su proizašla iz ovih incidenata dovoljno su kratka da se zapamte. Pričvrstite rezultate zapisa funkcija na lokalnu varijablu prije čitanja polja. Prolazite kroz nizove s fiksnim granicama koristeći Low() i High(), te pokrenite barem jednu provjeru raspona ili FPC kompilaciju prije nego što povjerujete novom demo projektu. Eksplicitno pretvarajte anonimna polja dinamičkih nizova ili ih deklarirajte imenovanim tipovima i izgradite cijelu matricu prevoditelja prije izdanja. Potpuno izbacite sirove visoke bajtove iz spajanja AnsiStringova. Ništa od ovoga ne košta mjerljiv trud kada jednom postane navika, a svaka stavka zatvara način neuspjeha koji radni tijek s jednim prevoditeljem strukturno ne može uočiti

Sva četiri problema pronađena su i ispravljena tijekom održavanja komponente PDFium Component, koja isporučuje isti Object Pascal izvorni kod za Delphi, C++Builder i FPC/Lazarus te pokreće svoje testne sukladnosti i regresije na svakom od tih lanaca alata, tako da su zamke u ovom članku osigurane testovima, a ne sjećanjem