Articol tehnic

Delphi vs FPC: 4 capcane ascunse în codul PDF din build-urile PDFium

Același cod sursă Object Pascal se poate comporta diferit în Delphi și FPC/Lazarus în patru moduri care afectează frecvent codul componentei PDFium: FPC elimină înregistrările temporare rezultate din funcții înainte ca un test de apartenență in să le citească complet, dcc32 este livrat cu verificarea limitelor dezactivată, astfel încât indicii de tablou în afara limitelor citesc date eronate în mod silențios, doar Delphi 13 acceptă atribuirea unui tablou anonim array of Byte la TBytes fără conversie, iar concatenarea AnsiString din Delphi poate distruge octeți egali sau mai mari de $80 printr-o conversie implicită a paginii de cod. Fiecare dintre acestea generează un set de rezultate corect pe un compilator și greșit, sau mai rău, eronat în mod silențios, pe celălalt

Dacă configurați un proiect cu două compilatoare pentru prima dată, ghidul vizualizatorului Lazarus și FPC acoperă calea optimă: pachete, căi de căutare și afișarea unei ferestre de randare. Acest articol este opusul unui tutorial. Reprezintă lista problemelor pe care le-am întâlnit după ce calea optimă a funcționat, când testarea CI a fost corectă sub FPC, corectă sub Delphi, iar apoi o modificare ce a trecut pe o parte a eșuat pe cealaltă. Fiecare capcană de mai jos provine dintr-un eșec real în setul de teste PDFiumPas sau din demo-urile sale, analizele la nivel de commit fiind sintetizate într-o reproducere minimă, cauza principală și remedierea standardizată

De ce un set este citit ca fiind gol sub FPC, dar nu și în Delphi?

Pe scurt: FPC poate finaliza variabila temporară care stochează rezultatul de tip înregistrare al unei funcții înainte ca o expresie care citește un câmp al acelui rezultat să se fi încheiat, astfel încât testul X in Func().Issues poate verifica apartenența în raport cu un set deja eliberat, în timp ce expresia echivalentă din Delphi funcționează. Testele noastre de conformitate PDF/E au întâlnit această problemă in prima lor versiune. Validatorul returnează o înregistrare a cărei proprietate Issues este un set de marcaje de încălcare, iar instrucțiunile assert conțineau apelul direct în linie (inlined)

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

Forma directă citea setul ca fiind gol sub FPC, astfel încât fiecare instrucțiune assert care se aștepta la un marcaj a eșuat, în timp ce build-ul identic din Delphi a fost corect. Cauza principală este o diferență în modul în care cele două compilatoare gestionează durata de viață a obiectelor temporare rezultate din funcții în cadrul expresiilor mari: Delphi menține obiectul temporar activ până la sfârșitul instrucțiunii, în timp ce eliminarea de către FPC a înregistrării temporare poate intra în conflict cu operatorul de apartenență la set care încă o citește. Documentasem deja același comportament anterior într-un comentariu la funcția FlagPresent din unitatea de testare PDF/A, dar am reintrodus eroarea scriind teste noi de la zero, ceea ce arată cât de natural pare forma incorectă. Remedierea este simplă și merită adoptată ca regulă generală: nu asociați niciodată un acces la câmp sau un test de set direct unui apel de funcție care returnează o înregistrare; atribuiți mai întâi rezultatul unei variabile locale, apoi citiți câmpul. Acest lucru costă o singură linie și elimină o întreagă categorie de probleme dependente de compilator

De ce acceptă Delphi un index de tablou pe care FPC refuză să îl compileze?

Pe scurt: dcc32 compilează un index în afara limitelor într-un tablou cu dimensiuni fixe și, cu verificarea implicită a limitelor dezactivată, citește sau scrie memoria adiacentă la rulare fără nicio eroare, în timp ce FPC respinge același index la compilare. Componenta PDFium declară punctele patrulatere ca un tablou indexat de la 1, TQuadrilateralPoint = array [1..4] of TPdfPoint, corespunzând modului în care sunt de regulă numerotate intrările QuadPoints în formatul PDF. Un demo care îl completa folosind o buclă cu indexarea de la 0 a funcționat luni de zile în Delphi

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;

Build-ul din Delphi a fost un fals pozitiv: cu verificarea limitelor oprită, adică starea implicită dcc32, indexul 0 a accesat câmpul care preceda tabloul în înregistrare, iar demo-ul părea să funcționeze. Portarea aceluiași demo în Lazarus a generat imediat o eroare de verificare a limitelor la compilare din partea FPC, iar corectarea indexului a scos la iveală o a doua eroare în calea de adnotare a bibliotecii pe care citirile eronate o mascau, cea analizată în articolul despre adnotarea punctelor patrulatere. Două lecții au rezultat din acest incident. În primul rând, preferați Low() și High() în locul limitelor fixe atunci când tipul tabloului nu este definit cu indexarea de la 0. În al doilea rând, tratați compilarea FPC sau cel puțin un build Delphi cu directiva {$R+} activată ca pe o etapă obligatorie pentru orice demo sau test nou: setările implicite ale dcc32 nu vă vor semnala această categorie de erori, iar rularea programului nu reprezintă o dovadă a corectitudinii sale

Atribuirea TBytes pe care doar Delphi 13 o acceptă

Pe scurt: atribuirea unui câmp declarat ca array of Byte anonim la o variabilă TBytes este compilată în Delphi 13 (versiunea de compilator 37.0), dar eșuează în Delphi 12 Athens și în versiunile anterioare cu eroarea E2010 Incompatible types: 'TArray<Byte>' and 'Dynamic array'. Această diferență nu este una între Delphi și FPC, ci una între Delphi și versiunile sale anterioare, dar afectează codul în același mod: noul compilator acceptă în mod silențios o structură pe care restul versiunilor o resping

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;

Am livrat exact această structură într-o rutină de validare, dezvoltată și testată local în Delphi 13, unde conversia implicită a fost acceptată. Însă kitul de instalare cu codul sursă deservește un număr mare de utilizatori de Delphi 12 și versiuni anterioare, iar pentru aceștia unitatea nu a putut fi compilată. Soluția este fie conversia directă prezentată mai sus — sigură deoarece tabloul anonim array of Byte și TBytes partajează aceeași structură de tablou dinamic — fie, de preferat, declararea inițială a câmpului cu un tip numit precum TBytes pentru a evita conversiile. Soluția de proces este mai importantă: o structură compilată pe cel mai nou set de instrumente nu garantează funcționarea pe compilatoarele mai vechi utilizate de utilizatori, iar această categorie de regresie este invizibilă până când nu compilați codul pe toate versiunile acceptate. Scripturile noastre de lansare compilează acum biblioteca pe întreaga matrice de compilatoare, tocmai pentru că un build local 37.0 nu poate depista o permisiune specifică versiunii 13

Octetul AnsiString care dispare pe un sistem Windows în chineză

Pe scurt: concatenarea unui octet brut egal sau mai mare de $80 într-un AnsiString folosind operatorul + poate înlocui în mod silențios acel octet cu ? ($3F) sub Delphi, deoarece expresia trece printr-o conversie implicită AnsiString -> UnicodeString -> AnsiString prin pagina de cod a sistemului. Am descoperit acest lucru prin intermediul unui test PDF/A care construia un nume ce conținea un octet $FE izolat (care nu este un octet de început valid în UTF-8) pentru a verifica dacă validatorul marchează numele care nu sunt UTF-8 valide conform ISO 19005-2 clauza 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;

Pe un sistem Windows în limba chineză cu pagina de cod 936, șirul concatenat nu a conținut deloc valoarea $FE, astfel încât biblioteca nu a raportat nicio eroare, iar testul a eșuat, părând o eroare de bibliotecă. Biblioteca a funcționat corect: un test FPC care procesa un fișier PDF ce conținea octetul $FE a returnat rezultatul așteptat. Coruperea s-a produs în interiorul fișierului executabil de test din Delphi în timpul evaluării expresiei de șir, deoarece modelul Unicode din Delphi convertește expresiile AnsiString mixte prin UnicodeString, iar $FE nu este un octet de început valid în CP936, așa că procesul de conversie îl înlocuiește. Trebuie menționat că pe o pagină de cod occidentală pe un singur octet precum CP1252 aceeași expresie de regulă funcționează, motiv pentru care această eroare nu apare pe majoritatea mașinilor de dezvoltare și se manifestă doar pe sisteme din Asia de Est sau pe servere CI localizate. Regula pe care am adoptat-o: nu construiți niciodată vectori binari de test care conțin octeți egali sau mai mari de $80 prin concatenare AnsiString; fie modificați octeții direct după definirea șirului (ca mai sus), fie construiți vectorul direct în TBytes

Ce ar trebui să verifice în mod implicit un proces de lucru cu două compilatoare

Patru capcane, un singur model: fiecare compilator semnalează o categorie diferită de erori. Analiza limitelor din FPC la compilare a depistat un index în afara limitelor pe care dcc32 l-a rulat în mod silențios timp de luni de zile, în timp ce modelul Unicode din Delphi a expus o dependență de pagina de cod pe care un build FPC bazat pur pe octeți nu ar fi declanșat-o. Consecința practică este că nicio testare izolată nu este suficientă. Compilarea încrucișată nu este doar un criteriu de portabilitate, ci reprezintă un al doilea analizator static și un al doilea model de rulare aplicate aceleiași surse, în mod similar verificărilor de siguranță descrise în articolul despre securizarea ABI și a memoriei

Regulile care au rezultat din aceste incidente sunt simplu de reținut: atribuiri rezultatele de tip înregistrare ale funcțiilor unei variabile locale înainte de a citi câmpurile; parcurgeți tablourile cu dimensiuni fixe utilizând Low() și High() și rulați cel puțin un build range-checked sau FPC înainte de a valida orice demo nou; convertiți explicit tablourile dinamice anonime sau declarați-le cu tipuri numite și compilați întreaga matrice înainte de lansare; evitați complet octeții bruți mari la concatenarea AnsiString. Niciuna dintre acestea nu implică eforturi suplimentare odată ce devin obiceiuri, iar fiecare elimină un mod de eșec pe care un proces cu un singur compilator nu îl poate detecta prin structura sa

Toate cele patru probleme au fost identificate și remediate în timpul activităților de întreținere a PDFium Component, care livrează același cod sursă Object Pascal pentru Delphi, C++Builder și FPC/Lazarus și își rulează testele de conformitate și regresie pe fiecare dintre aceste medii, astfel încât problemele din acest articol sunt prevenite prin teste, nu prin memorie