Technický článek

Delphi vs. FPC: 4 skrytá úskalí kódu PDF v sestaveních PDFium

Stejný zdrojový kód v Object Pascalu se může pod Delphi a FPC/Lazarus chovat odlišně ve čtyřech případech, které opakovaně postihují kód komponenty PDFium Component: FPC likviduje dočasné záznamy výsledků funkcí dříve, než operátor členství in dokončí jejich čtení, kompilátor dcc32 je dodáván s vypnutou kontrolou rozsahů, takže indexy polí mimo rozsah tiše čtou z paměti náhodná data, pouze Delphi 13 přijímá přiřazení anonymního array of Byte do TBytes bez přetypování a zřetězení typu AnsiString v Delphi může zničit bajty s hodnotou $80 nebo vyšší kvůli skrytému převodu přes systémovou kódovou stránku. Každý z těchto případů způsobuje, že testovací sada je na jednom kompilátoru zelená (úspěšná) a na druhém červená (chybná), nebo v horším případě tiše vrací nesprávné výsledky

Pokud poprvé nastavujete projekt pro dva kompilátory, průvodce prohlížečem pro Lazarus a FPC popisuje tu bezproblémovou cestu: balíčky, vyhledávací cesty a zobrazení vykreslovacího okna na obrazovce. Tento článek je opakem tutoriálu. Je to seznam věcí, na které jsme narazili poté, co bezproblémová cesta začala fungovat — když CI (kontinuální integrace) byla zelená pod FPC, zelená pod Delphi a pak změna, která na jedné straně prošla, na druhé vybuchla. Každé níže uvedené úskalí pochází ze skutečného selhání v testovací sadě PDFiumPas nebo jejích ukázkách, přičemž forenzní analýza na úrovni commitů je zkrácena do minimální reprodukce chyb, popisu hlavní příčiny a opravy, kterou jsme standardizovali

Proč se množina pod FPC načte jako prázdná, ale v Delphi nikoli?

Vysvětlení jednou větou: FPC může ukončit platnost dočasné proměnné držící výsledek funkce typu záznam (record) ještě předtím, než skončí vyhodnocování výrazu, který z tohoto výsledku čte pole, takže X in Func().Issues může testovat členství vůči již uvolněné množině, zatímco ekvivalentní výraz v Delphi funguje. Naše testy shody s PDF/E na to narazily ve své první verzi. Validátor vrací záznam, jehož pole Issues je množinou příznaků porušení pravidel, a assertiony toto volání vkládaly přímo (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 s přímým voláním (inlined) vyhodnotila pod FPC množinu jako prázdnou, takže každý assertion očekávající příznak selhal, zatímco identické sestavení v Delphi prošlo. Hlavní příčinou je rozdíl v tom, jak oba kompilátory spravují životnost dočasných výsledků funkcí uvnitř větších výrazů: Delphi udržuje dočasnou proměnnou naživu až do konce příkazu, zatímco uvolnění dočasného záznamu kompilátorem FPC může předběhnout operátor členství v množině, který z něj stále čte. Stejné chování jsme již jednou zdokumentovali v komentáři u pomocné funkce FlagPresent v testovací jednotce PDF/A, a přesto jsme tuto chybu zavedli znovu při psaní nových testů od nuly, což ukazuje, jak přirozeně tato chybná forma vypadá. Oprava je mechanická a stojí za to ji přijmout jako plošné pravidlo: nikdy neřetězte přístup k poli nebo test množiny přímo na volání funkce, která vrací záznam; nejprve přiřaďte výsledek do lokální proměnné a teprve poté čtěte pole. Stojí to jeden řádek navíc a odstraňuje to celou třídu nestability závislé na kompilátoru

Proč Delphi přijímá index pole, který FPC odmítá přeložit?

Vysvětlení jednou větou: dcc32 přeloží index mimo rozsah do pole s pevnými hranicemi a při výchozím vypnutém hlídání rozsahů za běhu tiše čte nebo zapisuje sousední paměť bez jakékoli chyby, zatímco FPC stejný index odmítne již při kompilaci. Komponenta PDFium Component deklaruje body čtyřúhelníku jako pole indexované od 1, TQuadrilateralPoint = array [1..4] of TPdfPoint, což odpovídá tomu, jak bývají položky QuadPoints v PDF obvykle číslovány. Ukázka, která jej plnila pomocí reflexivního cyklu od 0, v Delphi bez problémů fungovala měsíce

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;

Sestavení v Delphi vykazovalo falešně pozitivní výsledek: při vypnuté kontrole rozsahů (což je výchozí stav v dcc32) index 0 přistál na jakémkoli poli, které v záznamu předchází poli, a ukázka se zdála běžet normálně. Portování stejné ukázky na Lazarus vyvolalo okamžitou chybu kontroly rozsahu při kompilaci ve FPC a oprava indexu poté odhalila druhou, hlubší chybu v cestě anotací knihovny, kterou čtení náhodných dat maskovalo — tu, která je rozebrána v článku o anotacích s body QuadPoints. Z tohoto incidentu vyplynula dvě ponaučení. Za prvé, dávejte přednost Low() a High() před literálními hranicemi, kdykoli typ pole není z konstrukce indexován od 0. Za druhé, považujte kompilaci ve FPC (nebo alespoň sestavení v Delphi se zapnutým parametrem {$R+}) za povinnou kontrolu při prvním spuštění jakékoli nové ukázky nebo testu: výchozí nastavení dcc32 vám o této třídě chyb neřekne a běžící program není důkazem své správnosti

Přiřazení TBytes, které přijímá pouze Delphi 13

Vysvětlení jednou větou: přiřazení pole deklarovaného jako anonymní array of Byte do proměnné typu TBytes se v Delphi 13 (verze kompilátoru 37.0) přeloží, ale v Delphi 12 Athens a všech starších verzích selže s chybou E2010 Incompatible types: 'TArray<Byte>' and 'Dynamic array'. V tomto případě se nejedná o rozpor mezi Delphi a FPC, ale spíše o rozpor Delphi s jeho vlastní minulostí, ovšem stejným způsobem zasahuje stejný multi-kompilátorový kód: nejnovější kompilátor tiše přijímá konstrukci, kterou vše ostatní odmítá

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;

Přesně to jsme dodali ve validační rutině vyvíjené a testované lokálně v Delphi 13, kde byl implicitní převod tiše akceptován. Instalační program s plnými zdrojovými kódy slouží velkému počtu uživatelů na Delphi 12 a starších a u nich se jednotka jednoduše nepřeložila. Přímo z toho vyplývající strukturální opravou je buď výše uvedené přímé přetypování (hard cast), které je bezpečné, protože anonymní array of Byte a TBytes sdílejí identické rozložení dynamického pole v paměti, nebo lépe deklarování pole jako pojmenovaného typu, například TBytes, přímo od začátku, aby žádný převod vůbec nenastal. Oprava procesu je však důležitější: konstrukce, která se přeloží na vašem nejnovějším nástroji, nedokazuje nic o starších kompilátorech, které vaši uživatelé skutečně používají, a tato kategorie regresí je neviditelná, dokud neprovedete sestavení proti každé podporované verzi. Naše skripty pro vydávání verzí nyní kompilují knihovnu napříč celou maticí kompilátorů právě proto, že lokální sestavení ve verzi 37.0 nemůže zachytit shovívavost specifickou pro verzi 13

Bajt typu AnsiString, který mizí na čínském systému Windows

Vysvětlení jednou větou: zřetězení surového bajtu s hodnotou $80 nebo vyšší do AnsiStringu pomocí operátoru + může v Delphi tiše nahradit tento bajt znakem ? ($3F), protože výraz provádí implicitní převod z AnsiStringu do UnicodeStringu a zpět do AnsiStringu přes systémovou kódovou stránku. Zjistili jsme to díky testu PDF/A, který vytváří název obsahující izolovaný bajt $FE (což nikdy není platný počáteční bajt UTF-8), aby se ověřilo, že validátor označí názvy, které nejsou platným UTF-8 podle normy ISO 19005-2 klauzule 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 čínském systému Windows s kódovou stránkou 936 zřetězený řetězec vůbec neobsahoval bajt $FE, takže knihovna správně nic nenahlásila a test selhal, což vypadalo jako chyba v knihovně. Knihovna přitom nikdy nechybovala: testovací systém pod FPC, který předložil PDF soubor skutečně obsahující bajt $FE, dostal očekávaný příznak. K poškození došlo uvnitř testovacího spustitelného souboru Delphi během vyhodnocování řetězcového výrazu, protože Unicode-first model řetězců v Delphi převádí smíšené výrazy AnsiString přes UnicodeString a $FE není platným počátečním bajtem v CP936, takže převod jej nahradí. Uveďme si limity na rovinu: na jednobajtových západních kódových stránkách, jako je CP1252, stejný výraz obvykle přežije, což je přesně důvod, proč se tato chyba skrývá na většině vývojářských strojů a objevuje se pouze na východoasijských systémech nebo lokalizovaných CI strojích. Pravidlo, které jsme přijali: nikdy nesestavujte binární testovací vektory obsahující bajty s hodnotou $80 nebo vyšší pomocí zřetězení AnsiString; buď opravte bajty na místě po ustálení řetězce (viz výše), nebo od začátku sestavujte vektor v TBytes

Co by měl workflow se dvěma kompilátory standardně kontrolovat

Čtyři úskalí, jeden vzorec: každý kompilátor vám řekne o jiné podskupině vašich chyb. Analýza rozsahu při kompilaci ve FPC zachytila index mimo rozsah, který dcc32 nechal běžet bez povšimnutí měsíce, a Unicode model řetězců v dcc32 odhalil závislost na kódové stránce, kterou čistě bajtově orientované sestavení ve FPC nikdy nevyvolá. Praktickým důsledkem je, že ani jedna samostatně zelená linka (pipeline) nestačí. Cross-kompilace není jen zaškrtávacím políčkem přenositelnosti, je to druhý statický analyzátor a druhý runtime model aplikovaný na stejný zdrojový kód, ve stejném duchu jako defenzivní kontroly hranic v článku o zpevnění ABI a paměťové bezpečnosti

Pravidla, která z těchto incidentů vyplynula, jsou dostatečně krátká na to, abyste si je zapamatovali. Před čtením polí přiřaďte výsledky funkcí typu záznam do lokální proměnné. Procházejte pole s pevnými hranicemi pomocí Low() a High() a předtím, než začnete důvěřovat jakékoli nové ukázce, spusťte alespoň jedno sestavení s kontrolou rozsahů nebo sestavení ve FPC. Přetypujte anonymní pole dynamických polí explicitně nebo je deklarujte s pojmenovanými typy a před vydáním sestavte kompletní matici kompilátorů. Zcela vynechejte surové vysoké bajty ze zřetězení typů AnsiString. Nic z toho nestojí měřitelné úsilí, jakmile se to stane zvykem, a každé z nich uzavírá chybový stav, který workflow s jediným kompilátorem strukturálně nedokáže odhalit

Všechny čtyři problémy byly nalezeny a opraveny během údržby komponenty PDFium Component, která dodává stejné zdrojové kódy v Object Pascalu pro Delphi, C++Builder a FPC/Lazarus a spouští své sady pro ověřování shody a regresí na každém z těchto nástrojů, takže úskalí popsaná v tomto článku jsou jištěna testy spíše než pamětí