Odborný článok

Delphi vs FPC: 4 skryté úskalia PDF kódu v zostaveniach PDFium

Rovnaký zdrojový kód v Object Pascale sa môže pod Delphi a FPC/Lazarus správať odlišne štyrmi spôsobmi, ktoré opakovane zasahujú kód komponentu PDFium: FPC odstraňuje dočasné záznamy výsledkov funkcií skôr, ako operátor členstva in dokončí ich čítanie; dcc32 sa dodáva s vypnutou kontrolou rozsahov, takže indexy polí mimo rozsah potichu čítajú odpad; iba Delphi 13 akceptuje priradenie anonymného array of Byte do TBytes bez pretypovania; a spájanie AnsiStringov v Delphi môže zničiť bajty na hodnote $80 alebo nad ňou kvôli skrytej konverzii kódovej stránky. Každý z týchto prípadov vytvára kód, ktorý prejde na jednom kompilátore a zlyhá (alebo čo je horšie, správa sa potichu nesprávne) na druhom

Ak nastavujete projekt pre dva kompilátory prvýkrát, sprievodca prehliadačom pre Lazarus a FPC popisuje tú bezproblémovú cestu: balíky, vyhľadávacie cesty a zobrazenie okna vykresľovania na obrazovke. Tento článok je opakom tutoriálu. Je to zoznam problémov, na ktoré sme narazili po tom, čo táto cesta začala fungovať, keď bol testovací systém zelený pod FPC aj pod Delphi a potom zmena, ktorá na jednej strane prešla, na druhej vybuchla. Každé úskalie nižšie pochádza z reálneho zlyhania v testovacej sade PDFiumPas alebo jej ukážkach, pričom analýza na úrovni commitov je zhrnutá do minimálnej reprodukcie, hlavnej príčiny a riešenia, ktoré sme štandardizovali

Prečo sa množina pod FPC javí ako prázdna, ale v Delphi nie?

Verzia jednou vetou: FPC môže uvoľniť dočasnú premennú obsahujúcu záznam výsledku funkcie skôr, ako sa dokončí vyhodnotenie výrazu čítajúceho pole tohto výsledku, takže test členstva X in Func().Issues sa môže vykonávať voči už uvoľnenej množine, zatiaľ čo ekvivalentný výraz v Delphi funguje. Naše testy zhody PDF/E na to narazili hneď v prvej verzii. Validátor vracia záznam, ktorého pole Issues je množinou príznakov porušenia, a asercie toto volanie vkladali priamo do riadku (inline)

Diagram ukazujúci, že Delphi drží záznam výsledku funkcie PDFium dočasne nažive, až kým príkaz neskončí, zatiaľ čo FPC ho uvoľní skôr, než operátor in prečíta množinu Issues, takže asercia prejde len pod Delphi
Delphi drží dočasný záznam výsledku funkcie nažive až do konca príkazu, zatiaľ čo FPC ho môže uvoľniť skôr, než operátor in prečíta množinu Issues
// Nespoľahlivé pod FPC: dočasný záznam výsledku funkcie
// môže byť uvoľnený skôr, ako test 'in' prečíta Issues
AssertTrue(pveiLzwUsed in ValidateAnsi(Pdf).Issues);

// Spoľahlivé na oboch kompilátoroch: najprv ulož výsledok do lokálnej premennej
var
  Vr: TPdfEValidationResult;
begin
  Vr := ValidateAnsi(Pdf);
  AssertTrue(pveiLzwUsed in Vr.Issues);
end;

Vložená (inline) forma čítala množinu pod FPC ako prázdnu, takže každá asercia, ktorá očakávala príznak, zlyhala, hoci identická zostava pod Delphi prešla. Hlavnou príčinou je rozdiel v tom, ako oba kompilátory spravujú životnosť dočasných výsledkov funkcií vo vnútri väčších výrazov: Delphi udržuje dočasnú premennú nažive až do konca príkazu, zatiaľ čo uvoľnenie dočasného záznamu vo FPC môže predbehnúť operátor testu množinovej príslušnosti, ktorý ju stále číta. Rovnaké správanie sme už raz predtým zdokumentovali v komentári k pomocnej funkcii FlagPresent v testovacej jednotke PDF/A, a napriek tomu sme túto chybu opäť zaviedli pri písaní nových testov od nuly, čo napovedá, ako prirodzene táto chybná forma vyzerá. Oprava je mechanická a oplatí sa ju prijať ako všeobecné pravidlo: nikdy nereťaziť prístup k poľu alebo test množiny priamo na volanie funkcie, ktorá vracia záznam; najprv priraďte výsledok do lokálnej premennej a až potom čítajte pole. Stojí to jeden riadok naviac a odstraňuje to celú triedu chýb závislých od kompilátora

Prečo Delphi akceptuje index poľa, ktorý FPC odmieta skompilovať?

Verzia jednou vetou: dcc32 skompiluje index mimo rozsahu do poľa s pevnými hranicami a pri predvolene vypnutej kontrole rozsahov číta alebo zapisuje susednú pamäť za behu bez akejkoľvek chyby, zatiaľ čo FPC rovnaký index odmietne už pri kompilácii. Komponent PDFium deklaruje body štvoruholníka ako pole indexované od 1, TQuadrilateralPoint = array [1..4] of TPdfPoint, čo zodpovedá zvyčajnému číslovaniu položiek QuadPoints v PDF. Ukážka, ktorá ho plnila pomocou klasického cyklu od 0, fungovala pod Delphi celé mesiace

Diagram PDFium Component poľa quad-points PDF od jednej, kde index 0 pod dcc32 mlčky dotkne susedné pole záznamu, zatiaľ čo FPC zastaví tú istú slučku chybou kontroly rozsahu v čase kompilácie
S vypnutou range kontrolou dcc32 pristane index 0 potichu na susednom poli záznamu, zatiaľ čo FPC odmietne tú istú slučku pri kompilácii
var
  I: Integer;
begin
  for I := 0 to 3 do                       // zle: pole je [1..4]
    Data.AttachmentPoints[I] := Corner[I]; // predvolené dcc32: skompiluje sa, index 0
                                           // potichu zasiahne susednú pamäť
                                           // FPC: chyba kontroly rozsahu pri kompilácii
  for I := Low(TQuadrilateralPoint) to High(TQuadrilateralPoint) do
    Data.AttachmentPoints[I] := Corner[I - 1];  // správne na oboch kompilátoroch
end;

Zostava pod Delphi bola falošne pozitívna: pri vypnutej kontrole rozsahov, čo je predvolené nastavenie dcc32, index 0 dopadol na ľubovoľné pole predchádzajúce poľu v zázname a ukážka vyzerala, že beží správne. Prenesenie rovnakej ukážky do Lazarusu okamžite vyvolalo chybu kontroly rozsahu pri kompilácii vo FPC, a oprava indexu následne odhalila druhú, hlbšiu chybu v ceste anotácií knižnice, ktorú odpadové čítania predtým maskovali — tú, ktorá je rozobraná v článku o anotáciách s bodmi štvoruholníka. Z tohto incidentu vyplynuli dve ponaučenia. Po prvé, uprednostňujte Low() a High() pred doslovnými hranicami vždy, keď typ poľa nie je zo svojej podstaty indexovaný od 0. Po druhé, považujte kompiláciu pod FPC, alebo aspoň jednu zostavu Delphi so zapnutým {$R+}, za povinnú bránu pri prvom spustení akejkoľvek novej ukážky alebo testu: predvolené nastavenia dcc32 vás o tejto triede chýb neupozornia, a program, ktorý beží, nie je dôkazom, že je správny

Priradenie do TBytes, ktoré akceptuje iba Delphi 13

Verzia jednou vetou: priradenie poľa deklarovaného ako anonymné array of Byte do premennej typu TBytes sa skompiluje v Delphi 13 (verzia kompilátora 37.0), ale zlyhá na Delphi 12 Athens a všetkých starších verziách s chybou E2010 Incompatible types: 'TArray<Byte>' and 'Dynamic array'. Toto nie je ani tak rozdiel medzi Delphi a FPC, ako skôr rozdiel medzi Delphi a jeho vlastnou minulosťou, no zasahuje rovnaký viac-kompilátorový kód rovnakým spôsobom: najnovší kompilátor potichu akceptuje konštrukciu, ktorú všetky ostatné odmietajú

type
  TValidator = class
  private
    FBuffer: array of Byte;   // anonymný typ dynamického poľa
  end;

var
  OrigBytes: TBytes;
begin
  OrigBytes := FBuffer;          // iba Delphi 13; E2010 na Delphi 12
                                 // Athens a starších
  OrigBytes := TBytes(FBuffer);  // skompiluje sa všade; rovnaké rozloženie bajtov,
                                 // bezpečné tvrdé pretypovanie
end;

Presne toto sme nasadili vo validačnej rutine, vyvinutej a lokálne otestovanej na Delphi 13, kde bola implicitná konverzia potichu akceptovaná. Inštalátor s plným zdrojovým kódom obsluhuje veľké množstvo používateľov na Delphi 12 a starších, a pre nich sa jednotka jednoducho neskompilovala. Štrukturálnou opravou je buď tvrdé pretypovanie uvedené vyššie, ktoré je bezpečné, pretože anonymné array of Byte a TBytes zdieľajú identické rozloženie dynamického poľa, alebo lepšie, deklarovať pole rovno ako pomenovaný typ, napríklad TBytes, takže k žiadnej konverzii nikdy nedôjde. Dôležitejšia je oprava procesu: konštrukcia, ktorá sa skompiluje na vašom najnovšom nástrojovom reťazci, nič nedokazuje o starších kompilátoroch, ktoré vaši používatelia skutočne používajú, a táto kategória regresie je neviditeľná, kým nezostavíte kód proti každej podporovanej verzii. Naše release skripty teraz kompilujú knižnicu naprieč celou maticou kompilátorov práve preto, že lokálna zostava 37.0 nedokáže zachytiť zhovievavosť špecifickú len pre verziu 13

Bajt v AnsiStringu, ktorý na čínskom systéme Windows zmizne

Verzia jednou vetou: spájanie surových bajtov na hodnote $80 alebo vyššej do AnsiStringu pomocou + môže v Delphi potichu nahradiť tento bajt znakom ? ($3F), pretože výraz vykonáva skrytú obojsmernú konverziu AnsiString -> UnicodeString -> AnsiString cez kódovú stránku systému. Našli sme to vďaka testu PDF/A, ktorý vytvára názov obsahujúci izolovaný bajt $FE (čo nikdy nie je platný úvodný bajt UTF-8), aby sme overili, že validátor označí názvy, ktoré nie súp latným UTF-8 podľa ISO 19005-2 klauzuly 6.1.8

Diagram obojsmernej cesty AnsiString do UnicodeString, ktorá nahradí surový bajt $FE otáznikom na čínskom stroji Windows CP936, vedľa bezpečnej opravy bajtov na mieste v Delphi
Implicitný UnicodeString round-trip nahradí nemapovateľný bajt $FE bajtom $3F pod CP936, takže bezpečná cesta záplatuje bajt na mieste
var
  BadName: AnsiString;
begin
  // Na Delphi s viacbajtovou systémovou kódovou stránkou (pozorované na CP936)
  // spájanie prechádza cez UnicodeString a $FE, čo
  // nie je platná sekvencia CP936, sa vráti ako '?' ($3F)
  BadName := '/Bad' + AnsiChar($FE) + 'Name';

  // Bezpečné: zostavte s ASCII zástupným znakom, potom bajt opravte na mieste;
  // indexované priradenie do usadeného AnsiStringu neprechádza obojsmernou konverziou
  BadName := '/Bad' + #1 + 'Name';
  BadName[5] := AnsiChar($FE);
end;

Na čínskom systéme Windows s kódovou stránkou 936 spájaný reťazec vôbec neobsahoval bajt $FE, takže knižnica správne nič nenahlásila a test zčervenal, hoci to vyzeralo ako chyba knižnice. Knižnica sa však nemýlila: testovací program pod FPC, ktorý poslal PDF so skutočným bajtom $FE, dostal očakávaný príznak. K poškodeniu došlo vo vnútri testovacieho programu Delphi počas vyhodnocovania reťazcového výrazu, pretože Unicode-prvý model reťazcov v Delphi konvertuje zmiešané AnsiString výrazy cez UnicodeString a keďže $FE nie je platný úvodný bajt v CP936, obojsmerná konverzia ho nahradí. Priznajme si hranice: na jednobajtovej západnej kódovej stránke (napr. CP1252) rovnaký výraz zvyčajne prežije, čo je presne dôvod, prečo sa táto chyba skrýva na väčšine vývojárskych počítačov a objavuje sa len na východoázijských systémoch alebo lokalizovaných CI serveroch. Pravidlo, ktoré sme prijali: nikdy nevytvárať binárne testovacie vektory obsahujúce bajty $80 a viac spájaním AnsiStringov; buď upravte bajty na mieste po usadení reťazca (ako je uvedené vyššie), alebo vektor od začiatku zostavte v TBytes

Čo by mal pracovný postup pre dva kompilátory predvolene kontrolovať

Štyri úskalia, jeden vzor: každý kompilátor vám povie o inej časti vašich chýb. Analýza rozsahov kompilátora FPC odhalila index mimo rozsahu, ktorý dcc32 vykonával bez varovania celé mesiace, a Unicode model reťazcov v dcc32 odhalil závislosť na kódovej stránke, ktorú čisto bajtovo orientované zostavenie FPC nikdy nespustí. Praktickým dôsledkom je, že ani jedna úspešná linka sama o sebe nestačí. Cross-kompilácia nie je len políčkom pre prenosnosť, je to druhý statický analyzátor a druhý behový model aplikovaný na rovnaký kód, v rovnakom duchu ako defenzívne kontroly ohraničenia v článku o zosilnení ABI a pamäťovej bezpečnosti

Pravidlá, ktoré z týchto incidentov vyplynuli, sú dostatočne krátke na to, aby ste si ich zapamätali. Priraďte dočasné záznamy výsledkov funkcií do lokálnej premennej pred čítaním polí. Prechádzajte polia s pevnými hranicami pomocou Low() a High() a pred nasadením akejkoľvek novej ukážky spustite aspoň jedno zostavenie s kontrolou rozsahov alebo pod FPC. Pretypujte polia anonymných dynamických polí explicitne (alebo ich rovno deklarujte s pomenovanými typmi) a pred vydaním zostavte celú maticu kompilátorov. Surové vysoké bajty úplne vylúčte zo spájania AnsiStringov. Nič z toho nestojí merateľné úsilie, akonáhle sa z toho stanú návyky, a každý z týchto krokov uzatvára chybový stav, ktorý jednovláknový alebo jednokompilátorový vývoj zo svojej podstaty nedokáže zachytiť

Všetky štyri problémy boli odhalené a opravené počas údržby komponentu PDFium, ktorý poskytuje rovnaký zdrojový kód Object Pascal pre Delphi, C++Builder aj FPC/Lazarus a spúšťa svoje testy zhody a regresie na každom z týchto nástrojov, takže úskalia v tomto článku strážia testy a nie ľudská pamäť