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
// Unreliable under FPC: the function-result record temporary
// can be released before the 'in' test reads Issues
AssertTrue(pveiLzwUsed in ValidateAnsi(Pdf).Issues);
// Reliable on both compilers: pin the result to a local first
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
var
I: Integer;
begin
for I := 0 to 3 do // wrong: the array is [1..4]
Data.AttachmentPoints[I] := Corner[I]; // dcc32 default: compiles, index 0
// silently touches adjacent memory
// FPC: compile-time range check error
for I := Low(TQuadrilateralPoint) to High(TQuadrilateralPoint) do
Data.AttachmentPoints[I] := Corner[I - 1]; // correct on both compilers
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; // anonymous dynamic array type
end;
var
OrigBytes: TBytes;
begin
OrigBytes := FBuffer; // Delphi 13 only; E2010 on Delphi 12
// Athens and earlier
OrigBytes := TBytes(FBuffer); // compiles everywhere; same byte layout,
// safe hard cast
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
var
BadName: AnsiString;
begin
// On Delphi with a multi-byte system code page (observed on CP936),
// the concatenation round-trips through UnicodeString and $FE, which
// is not a valid CP936 sequence, comes back as '?' ($3F)
BadName := '/Bad' + AnsiChar($FE) + 'Name';
// Safe: build with an ASCII placeholder, then patch the byte in place;
// indexed assignment into a settled AnsiString does not round-trip
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