Techninis straipsnis

Delphi ir FPC palyginimas: 4 paslėpti PDF kodo spąstai PDFium kūrimuose

Tas pats „Object Pascal“ šaltinis „Delphi“ bei FPC/Lazarus aplinkose gali elgtis skirtingai keturiais būdais, kurie nuolat kelia problemų „PDFium Component“ kode: FPC atlaisvina funkcijos rezultato laikinuosius įrašus (record temporaries) dar nebaigus in narystės testui jų skaityti, „dcc32“ platinamas su išjungtu ribų tikrinimu, todėl už ribų esantys masyvo indeksai tyliai nuskaito šiukšles, tik „Delphi 13“ leidžia priskirti anoniminį array of Byte kintamajam TBytes be tipo konversijos, o „Delphi“ programoje atliekama „AnsiString“ sudėtis (concatenation) gali sugadinti baitus, lygus ar didesnius nei $80, dėl paslėpto kodo puslapio ciklo (round-trip). Kiekvienas iš šių atvejų sukuria testų rinkinį, kuris viename kompiliatoriuje rodo žalią šviesą, o kitame – raudoną, arba dar blogiau – tyliai veikia klaidingai

Jei pirmą kartą kuriate dviejų kompiliatorių projektą, Lazarus ir FPC peržiūros programos vadovas aprašo sėkmingą kelią: paketus, paieškos kelius ir vaizdo piešimo lango gavimą ekrane. Šis straipsnis yra priešingybė vadovui. Tai yra sąrašas dalykų, su kuriais susidūrėme jau po to, kai sėkmingas kelias suveikė, kai nuolatinis integravimas (CI) rodė žalią šviesą tiek FPC, tiek Delphi aplinkose, o tada vienoje pusėje praėjęs pakeitimas kitoje pusėje sukėlė sprogimą. Kiekviena žemiau aprašyta klaida kilo iš tikro gedimo „PDFiumPas“ testų rinkinyje arba jo pavyzdžiuose, o analizė buvo suspausta į minimalią reprodukciją, pagrindinę priežastį ir pataisymą, kurį mes standartizavome

Kodėl FPC aplinkoje aibė (set) nuskaitoma tuščia, o Delphi – ne?

Vieno sakinio versija: FPC gali užbaigti laikinąjį kintamąjį, saugantį funkcijos įrašo rezultato laikinąjį kintamąjį, prieš pasibaigiant išraiškai, kuri nuskaito to rezultato lauką, todėl X in Func().Issues gali tikrinti narystę jau atlaisvintoje aibėje, tuo tarpu ekvivalenti Delphi išraiška veikia. Mūsų PDF/E suderinamumo testai susidūrė su tuo savo pirmojoje versijoje. Tikrintuvas (validator) grąžina įrašą, kurio laukas Issues yra pažeidimų vėliavų aibė, o teiginiai (assertions) šį iškvietimą naudojo tiesiogiai eilutėje

Eilutėje (inlined) pateikta forma FPC aplinkoje aibę nuskaitė kaip tuščią, todėl visi teiginiai, kurie tikėjosi vėliavos, žlugo, tuo tarpu identiškas Delphi kūrinys praėjo sėkmingai. Pagrindinė priežastis yra skirtumas, kaip abu kompiliatoriai valdo funkcijos rezultato laikinųjų kintamųjų gyvavimo laiką didesnėse išraiškose: Delphi išlaiko laikinąjį kintamąjį gyvą iki teiginio pabaigos, tuo tarpu FPC atlaisvintas įrašo kintamasis gali aplenkti aibės narystės operatorių, kuris vis dar jį skaito. Tą patį elgesį jau buvome užfiksavę anksčiau, komentare apie FlagPresent pagalbinę funkciją PDF/A testavimo modulyje, tačiau vėliau vėl įvėlėme šią klaidą rašydami naujus testus nuo nulio, kas parodo, kaip natūraliai atrodo ši klaidinga forma. Pataisymas yra mechaninis ir jį verta priimti kaip bendrą taisyklę: niekada nejunkite prieigos prie lauko ar aibės patikros tiesiogiai prie funkcijos iškvietimo, kuris grąžina įrašą; pirmiausia priskirkite rezultatą vietiniam kintamajam, o tada nustatykite lauko reikšmę. Tai kainuoja vieną eilutę ir pašalina visą nuo kompiliatoriaus priklausančio nestabilumo klasę

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

Kodėl Delphi priima masyvo indeksą, kurio FPC atsisako kompiliuoti?

Vieno sakinio versija: „dcc32“ sukompiliuoja už ribų esantį indeksą į fiksuotų ribų masyvą ir, esant išjungtam numatytajam ribų tikrinimui, vykdymo metu nuskaito arba įrašo gretimą atmintį be jokios klaidos, tuo tarpu FPC atmeta tą patį indeksą kompiliavimo metu. „PDFium Component“ deklaruoja keturkampių taškus kaip masyvą nuo 1: TQuadrilateralPoint = array [1..4] of TPdfPoint, kas atitinka įprastą PDF „QuadPoints“ įrašų numeraciją. Demonstracinis pavyzdys, kuris užpildė jį nuliu pagrįstu ciklu, Delphi aplinkoje sėkmingai veikė mėnesius

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;

Delphi sukompiliavimas buvo klaidingai teigiamas (false positive): esant išjungtam ribų tikrinimui (kas yra numatytasis dcc32 nustatymas), indeksas 0 pateko į lauką, kuris yra prieš masyvą įraše, ir demonstracinis pavyzdys atrodė veikiantis. Šio pavyzdžio perkėlimas į „Lazarus“ iškart sukėlė FPC kompiliavimo laiko ribų tikrinimo klaidą, o indekso ištaisymas atskleidė antrąją, gilesnę klaidą bibliotekos anotacijų kelyje, kurią maskavo neteisingi nuskaitymai – ši klaida išanalizuota straipsnyje apie anotacijas su keturkampių taškais. Iš to pasimokėme dviejų dalykų. Pirma, teikite pirmenybę Low() bei High(), o ne skaitinėms riboms kaskart, kai masyvo tipas nėra sukurtas nuliu pagrįstu būdu. Antra, laikykite FPC kompiliavimą arba bent vieną Delphi sukompiliavimą su įjungtu {$R+} privalomu pirmuoju žingsniu bet kokiam naujam demonstraciniam pavyzdžiui ar testui: numatytieji „dcc32“ nustatymai nepraneš apie tokią klaidą, o veikianti programa nėra įrodymas, kad ji teisinga

TBytes priskyrimas, kurį priima tik Delphi 13

Vieno sakinio versija: lauko, deklaruoto kaip anoniminis array of Byte, priskyrimas kintamajam TBytes sėkmingai kompiliuojasi „Delphi 13“ (kompiliatoriaus versija 37.0), tačiau sugenda „Delphi 12 Athens“ ir visose ankstesnėse versijose su klaida E2010 Incompatible types: 'TArray<Byte>' and 'Dynamic array'. Tai nėra Delphi ir FPC skirtumas, o greičiau Delphi skirtumas nuo savo pačios praeities, zodžiu, tai veikia tą pačią kelių kompiliatorių kodo bazę: naujausias kompiliatorius tyliai priima konstrukciją, kurią atmeta visi kiti

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;

Būtent tai išleidome tikrinimo procedūroje, sukurtoje bei išbandytoje vietinėje aplinkoje su „Delphi 13“, kur ši numanoma konversija buvo tyliai priimta. Pilno kodo diegimo programa aptarnauja daug vartotojų su „Delphi 12“ ir senesnėmis versijomis, ir jiems šis modulis tiesiog nepasileido. Struktūrinis pataisymas yra arba griežtas tipo konvertavimas (hard cast), kaip parodyta aukščiau (kas yra saugu, nes anoniminis array of Byte ir TBytes dalijasi identišku dinaminio masyvo išdėstymu), arba, dar geriau, lauko deklaravimas kaip įvardyto tipo, pavyzdžiui, TBytes, iš pat pradžių, kad konversijos išvis nereikėtų. Proceso pataisymas yra svarbesnis: konstrukcija, kuri sukompiliuojama naujausioje įrankių grandinėje, neįrodo nieko apie senesnius kompiliatorius, kuriuos naudoja jūsų vartotojai, ir šios kategorijos regresija yra nematoma, kol nekompiliuojate su visomis palaikomomis versijomis. Mūsų platinimo scenarijai dabar kompiliuoja biblioteką visoje kompiliatorių matricoje būtent todėl, kad vietinis 37.0 variantas negali sugauti „Delphi 13“ specifinio nuolaidžiavimo

AnsiString baitas, kuris pradingsta kiniškoje „Windows“ mašinoje

Vieno sakinio versija: neapdoroto baito, lygaus ar didesnio nei $80, prijungimas prie „AnsiString“ naudojant operatorių + gali tyliai pakeisti šist baitą simboliu ? ($3F) „Delphi“ aplinkoje, nes išraiška atlieka netiesioginę konversiją iš „AnsiString“ į „UnicodeString“ ir vėl į „AnsiString“ per sistemos kodo puslapį. Tai sužinojome atlikdami PDF/A testą, kuris sukuria pavadinimą su izoliuotu $FE baitu, kuris niekada nėra teisingas UTF-8 pradžios baitas, kad įsitikintume, jog tikrintuvas pažymi pavadinimus, kurie nėra teisingi UTF-8 pagal ISO 19005-2 punktą 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;

Kiniškoje „Windows“ sistemoje, naudojančioje kodo puslapį 936, sujungtoje eilutėje išvis nebuvo $FE baito, todėl biblioteka teisingai nieko nepranešė, o testas nepasisekė (nuėjo į raudoną), nors tai atrodė kaip bibliotekos klaida. Biblioteka niekada neklydo: FPC testavimo aplinka, kuri pateikė PDF failą su tikruoju $FE baitu, gavo lauktą vėliavą. Sugadinimas įvyko Delphi testo vykdymo faile, kol buvo vertinama eilutės išraiška, nes Delphi į unikalųjį kodą orientuotas eilučių modelis konvertuoja mišrias AnsiString išraiškas per UnicodeString, o kadangi $FE nėra teisingas pradžios baitas CP936 puslapyje, šis ciklas jį tiesiog pakeičia. Būkime sąžiningi dėl šios ribos: vieno baito vakarietiškame kodo puslapyje, pavyzdžiui, CP1252, ta pati išraiška paprastai išgyvena, kas ir paaiškina, kodėl ši klaida pasislepia daugelyje kūrėjų mašinų ir iškyla tik Rytų Azijos sistemose arba lokalizuotose CI mašinose. Taisyklė, kurią priėmėme: niekada nekurkite dvejetainių testavimo vektorių, turinčių baitus nuo $80 ir daugiau, naudojant AnsiString sudėtį; arba pataisykite baitus vietoje, kai eilutė jau suformuota (kaip parodyta aukščiau), arba iš pat pradžių kurkite vektorių kintamajame TBytes

Ką dviejų kompiliatorių darbo eiga turėtų tikrinti pagal numatytuosius nustatymus

Keturi spąstai, vienas šablonas: kiekvienas kompiliatorius praneša apie skirtingą jūsų klaidų poaibį. FPC kompiliavimo laiko ribų analizė pagavo už ribų esantį indeksą, su kuriuo „dcc32“ tyliai veikė mėnesius, o „dcc32“ unikalaus kodo eilučių modelis atskleidė priklausomybę nuo kodo puslapio, kurios grynas baitinis FPC kompiliavimas niekada nesukelia. Praktinė pasekmė yra ta, kad vien tik žalio kodo pipeline neužtenka. Kryžminis kompiliavimas yra ne tik suderinamumo varnelė – tai antras statinis analizatorius bei antras vykdymo modelis, taikomas tam pačiam šaltiniui, vadovaujantis ta pačia dvasia, kaip ir apsauginės ribų patikros, aprašytos straipsnyje apie ABI ir atminties saugumo stiprinimą

Taisyklės, kurios išplaukė iš šių incidentų, yra pakankamai trumpos, kad jas įsimintume. Pirmiausia priskirkite funkcijos įrašo rezultatus vietiniam kintamajam prieš skaitydami laukus. Pereikite fiksuotų ribų masyvus naudodami Low() bei High() ir paleiskite bent vieną ribas tikrinantį arba FPC kūrimą prieš pasitikėdami nauju pavyzdžiu. Tipo konvertavimą anoniminiams dinaminių masyvų laukams atlikite aiškiai arba deklaruokite juos su įvardytais tipais ir prieš išleidimą sukurkite pilną kompiliatorių matricą. Visiškai nenaudokite neapdorotų aukštų baitų AnsiString sudėtyje. Nė vienas iš šių dalykų nekainuoja didelių pastangų, kai tampa įpročiu, ir kiekvienas iš jų užkerta kelią gedimams, kurių vieno kompiliatoriaus darbo eiga tiesiog negali pamatyti

Visos keturios problemos buvo surastos ir ištaisytos prižiūrint „PDFium Component“, kuri naudoja tą patį „Object Pascal“ kodą Delphi, C++Builder ir FPC/Lazarus aplinkose bei paleidžia suderinamumo ir regresijos testus kiekvienoje iš šių platformų, todėl šiame straipsnyje aprašyti spąstai yra saugomi testų, o ne atminties