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 pre nego što in test pripadnosti završi sa njihovim čitanjem, dcc32 dolazi sa isključenom proverom raspona pa indeksi niza van granica tiho čitaju smeće iz memorije, samo Delphi 13 prihvata dodeljivanje anonimnog array of Byte varijabli TBytes bez eksplicitnog pretvaranja (cast), a Delphi-jevo spajanje AnsiStringova može uništiti bajtove na vrednosti $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 prevodiocu, a crven, ili što je još gore, tiho pogrešan na drugom
Ako prvi put postavljate projekat sa dvostrukim prevodiocem, pregled Lazarus i FPC vizualizatora pokriva lakši put: pakete, staze pretraživanja i postavljanje prozora za renderovanje na ekran. 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 promena koja je prošla na jednoj strani detonirala na drugoj. Svaka zamka u nastavku dolazi iz stvarnog neuspeha u testnom skupu PDFiumPas ili njegovim demo projektima, sa forenzikom na nivou commita sažetom u minimalnu reprodukciju, temeljni uzrok i popravak koji smo standardizovali
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 pre 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 usaglašenosti 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 prevodioca upravljaju životnim vekom 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š uvek čita. Već smo jednom dokumentovali isto ponašanje u komentaru na pomoćnu funkciju FlagPresent u testnoj jedinici PDF/A, a zatim smo ponovo uveli istu grešku pišući nove testne ispočetka, što vam govori koliko prirodno izgleda taj neispravni oblik. Ispravak je mehanički i vredi ga usvojiti kao opšte pravilo: nikada ne povezujte pristup polju ili test skupa direktno na poziv funkcije koja vraća zapis; najpre dodelite rezultat lokalnoj varijabli, a zatim pročitajte polje. To košta jedan redak i uklanja celu klasu nestabilnosti ovisnih o prevodiocu
Zašto Delphi prihvata indeks niza koji FPC odbija kompajlirati?
Verzija u jednoj rečenici: dcc32 kompajlira indeks van raspona u niz sa fiksnim granicama i, sa onemogućenom zadanom proverom raspona, čita ili piše u susednu memoriju tokom izvršavanja bez ikakve greške, dok FPC odbija isti indeks u vreme kompajliranja. PDFium komponenta deklarise točke četvorougla kao 1-bazirani niz, TQuadrilateralPoint = array [1..4] of TPdfPoint, što odgovara uobičajenom numerisanju QuadPoints unosa u PDF-u. Demo koji ga je punio refleksivnom 0-baziranom petljom radio je mesecima 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: sa isključenom proverom raspona, što je zadana postavka za dcc32, indeks 0 je sleteo na bilo koje polje koje prethodi nizu u zapisu, a demo je naizgled radio. Portovanje istog demo projekta na Lazarus proizvelo je trenutnu grešku provere raspona u vreme kompajliranja iz FPC-a, a ispravljanje indeksa potom je otkrilo drugu, dublju grešku u stazi zabeleš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 dve 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 sa omogućenim {$R+}, kao obaveznu proveru za svaki novi demo ili test: zadane postavke dcc32 neće vas obavestiti o ovoj klasi grešaka, a program koji radi nije dokaz da je ispravan
Dodela TBytes koju prihvata samo Delphi 13
Verzija u jednoj rečenici: dodeljivanje polja deklarisanog kao anonimni array of Byte varijabli TBytes kompajlira se na Delphiju 13 (verzija prevodioca 37.0), ali ne uspeva na Delphiju 12 Athens i svim ranijim izdanjima uz grešku E2010 Incompatible types: 'TArray<Byte>' and 'Dynamic array'. Ovo nije toliko podela Delphi-naspram-FPC-a, koliko podela Delphi-naspram-svoje-vlastite-prošlosti, ali pogađa istu bazu koda sa više prevodioca na isti način: najnoviji prevodilac tiho prihvata 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 tačno to u validacionoj rutini, razvijenoj i testiranoj lokalno na Delphiju 13, gde je implicitna pretvorba bila tiho prihvaćena. Instalacioni program sa celovitim izvornim kodom služi velikom broju korisnika na Delphiju 12 i starijim verzijama, a za njih se jedinica jednostavno nije kompajlira. Strukturno rešenje je ili čvrsto pretvaranje tipa (hard cast) prikazano gore, što je bezbedno jer anonimni array of Byte i TBytes dele identičan raspored dinamičkog niza, ili još bolje, deklarisanje polja kao imenovanog tipa poput TBytes od samog početka kako do pretvorbe uopšte 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 prevodiocima 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 biblioteku kroz celu matricu prevodioca upravo zato što lokalna 37.0 kompilacija ne može uhvatiti popustljivost specifičnu za Delphi 13
Bajt AnsiStringa koji nestaje na kineskom sistemu Windows
Verzija u jednoj rečenici: spajanje sirovog bajta na vrednosti $80 ili više u AnsiString pomoću operatora + može tiho zameniti taj bajt znakom ? ($3F) pod Delphijem, jer izraz prolazi kroz implicitni povratni krug iz AnsiStringa u UnicodeString i nazad u AnsiString kroz sistemsku kodnu stranicu. To smo otkrili kroz PDF/A test koji konstruiše naziv koji sadrži izolovani bajt $FE, koji nikada nije ispravan UTF-8 vodeći bajt, kako bi proverio 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 sistemu koji koristi kodnu stranicu 936, spojeni niz uopšte nije sadržavao $FE, pa biblioteka opravdano nije prijavila ništa i test je propao, što je izgledalo kao greška biblioteke. Biblioteka 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 tokom procene izraza niza, jer Delphijev Unicode-prvi model znakovnih nizova pretvara mešovite AnsiString izraze kroz UnicodeString, a $FE nije valjan vodeći bajt u CP936 pa ga povratno putovanje zamenjuje. 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 mašina i pojavljuje se samo na istočnoazijskim sistemima ili lokalizovanim CI serverima. Pravilo koje smo usvojili: nikada ne gradite binarne testne vektore koji sadrže bajtove na $80 ili više spajanjem AnsiStringova; ili zakrpite bajtove na licu mesta nakon što se niz smiri, kao gore, ili od početka konstruišite vektor u tipu TBytes
Šta bi radni tok sa dvostrukim prevodiocem preradio prema zadanim postavkama
Četiri zamke, jedan obrazac: svaki prevodilac vas obaveštava o različitom podskupu vaših grešaka. FPC-ova analiza raspona u vreme kompajliranja uhvatila je indeks van granica koji je dcc32 tiho izvršavao mesecima, 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 posledica 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 primenjen na isti izvor, u istom duhu kao i odbrambene provere granica u članku o očvršćavanju ABI-ja i bezbednosti memorije
Pravila koja su proizašla iz ovih incidenata dovoljno su kratka da se zapamte. Pričvrstite rezultate zapisa funkcija na lokalnu varijablu pre čitanja polja. Prolazite kroz nizove sa fiksnim granicama koristeći Low() i High(), te pokrenite barem jednu proveru raspona ili FPC kompilaciju pre nego što poverujete novom demo projektu. Eksplicitno pretvarajte anonimna polja dinamičkih nizova ili ih deklarišite imenovanim tipovima i izgradite celu matricu prevodioca pre izdanja. Potpuno izbacite sirove visoke bajtove iz spajanja AnsiStringova. Ništa od ovoga ne košta merljiv trud kada jednom postane navika, a svaka stavka zatvara način neuspeha koji radni tok sa jednim prevodiocem strukturno ne može uočiti
Sva četiri problema pronađena su i ispravljena tokom 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 usaglašenosti i regresije na svakom od tih lanaca alata, tako da su zamke u ovom članku osigurane testovima, a ne sećanjem