Техническа статия

Delphi срещу FPC: 4 скрити капана в PDF кода при компилация с PDFium

Един и същ изходен код на Object Pascal може да се държи различно под Delphi и FPC/Lazarus по четири начина, които многократно засягат кода на компонента PDFium: FPC освобождава временните записи (records), върнати като резултат от функция, преди тестът за членство in да е приключил с четенето им; dcc32 се доставя с изключена проверка на диапазоните, така че излизащи извън границите индекси на масиви четат тихомълком невалидна памет; само Delphi 13 позволява присвояване на анонимен array of Byte към TBytes без преобразуване на типа; и конкатенацията на AnsiString в Delphi може да унищожи байтове със стойност $80 или по-висока чрез скрито двупосочно преобразуване на кодовата страница; Всеки от тези случаи води до ситуация, при която кодът е изряден при единия компилатор, но се проваля или — по-лошо — работи тихомълком грешно при другия

Ако настройвате проект с два компилатора за първи път, ръководството за Lazarus и FPC визуализатор описва лесния път: пакети, пътища за търсене и извеждане на прозорец за рендериране на екрана; Тази статия е обратното на урок; Тя е списък с проблеми, които срещнахме, след като лесният път проработи — когато CI тестовете бяха успешни под FPC и под Delphi, но промяна, преминала успешно при единия компилатор, се сриваше при другия; Всеки капан по-долу идва от реален проблем в тестовия пакет на PDFiumPas или неговите демонстрационни програми, като анализите са обобщени в пример за възпроизвеждане, първопричината и корекцията, която стандартизирахме

Защо под FPC даден набор (set) се чете като празен, но не и в Delphi?

С едно изречение: FPC може да финализира временната променлива, съдържаща записа от резултата на функция, преди изразът, който чете поле от този резултат, да е приключил, така че X in Func().Issues може да тества членство срещу вече освободен набор, докато еквивалентният израз в Delphi работи; Нашите тестове за съответствие с PDF/E се сблъскаха с това в първата си версия; Валидаторът връща запис, чието поле Issues е набор от флагове за нарушения, а твърденията (assertions) извикваха функцията в същия ред

Диаграма, показваща, че Delphi държи запис-резултат от PDFium функция временно жив до края на изречението, докато FPC го освобождава, преди операторът in да прочете множеството Issues, така че твърдението минава само под Delphi
Delphi държи временния function-result запис жив до края на statement-а, докато FPC може да го освободи, преди in-операторът да прочете множеството Issues
// Ненадеждно под FPC: временният запис от резултата на функцията
// може да бъде освободен, преди тестът 'in' да прочете Issues
AssertTrue(pveiLzwUsed in ValidateAnsi(Pdf).Issues);

// Надеждно и при двата компилатора: първо закрепете резултата в локална променлива
var
  Vr: TPdfEValidationResult;
begin
  Vr := ValidateAnsi(Pdf);
  AssertTrue(pveiLzwUsed in Vr.Issues);
end;

Вградената форма прочете набора като празен под FPC, така че всяко твърдение, което очакваше флаг, се проваляше, докато идентичната компилация с Delphi преминаваше успешно; Първопричината е разликата в начина, по който двата компилатора управляват жизнения цикъл на временните резултати от функции в по-големи изрази: Delphi поддържа временната променлива жива до края на оператора, докато освобождаването на временния запис от страна на FPC може да изпревари оператора за членство в набор, който все още го чете; Вече бяхме документирали същото поведение веднъж преди това в коментар към помощната функция FlagPresent в тестовия модул за PDF/A, и след това отново допуснахме същата грешка при писането на нови тестове, което показва колко естествено изглежда счупената форма; Корекцията е механична и си струва да се приеме като общо правило: никога не свързвайте достъп до поле или тест на набор директно към извикване на функция, която връща запис; първо присвоете резултата на локална променлива, след което прочетете полето; Това струва един ред и премахва цял клас зависима от компилатора нестабилност

Защо Delphi приема индекс на масив, който FPC отказва да компилира?

С едно изречение: dcc32 компилира индекс извън диапазона в масив с фиксирани граници и при изключена по подразбиране проверка на диапазоните тихомълком чете или записва съседна памет по време на изпълнение без никаква грешка, докато FPC отхвърля същия индекс още по време на компилация; Компонентът PDFium декларира точки на четириъгълник като 1-базиран масив, TQuadrilateralPoint = array [1..4] of TPdfPoint, съответстващ на начина, по който обикновено се номерират записите QuadPoints в PDF; Демонстрационна програма, която го попълваше с рефлексивен 0-базиран цикъл, работеше с месеци под Delphi

PDFium Component диаграма на PDF масив от quad-points, броен от 1, където индекс 0 под dcc32 тихо докосва съседното поле на записа, докато FPC спира същия цикъл с грешка при проверка на диапазона по време на компилация
С изключена range проверка на dcc32, индекс 0 мълчаливо каца на съседното поле на записа, докато FPC отхвърля същия цикъл още при компилация
var
  I: Integer;
begin
  for I := 0 to 3 do                       // грешно: масивът е [1..4]
    Data.AttachmentPoints[I] := Corner[I]; // dcc32 по подразбиране: компилира се, индекс 0
                                           // тихомълком докосва съседна памет
                                           // FPC: грешка за проверка на диапазона при компилация
  for I := Low(TQuadrilateralPoint) to High(TQuadrilateralPoint) do
    Data.AttachmentPoints[I] := Corner[I - 1];  // правилно и при двата компилатора
end;

Компилацията с Delphi беше фалшиво положителна: при изключена проверка на диапазоните, което е поведението на dcc32 по подразбиране, индекс 0 попадаше върху полето, което предшества масива в записа, и демонстрационната програма изглеждаше, че работи; Пренасянето на същата програма към Lazarus доведе до незабавна грешка за проверка на диапазона от страна на FPC при компилация, а коригирането на индекса разкри втори, по-дълбок бъг в пътя на анотациите на библиотеката, който маскираше невалидното четене — този, разгледан в статията за анотации с QuadPoints; От този инцидент произтекоха два урока; Първо, предпочитайте Low() и High() пред твърдо кодираните граници, когато типът на масива не е 0-базиран по дизайн; Второ, третирайте компилацията с FPC или поне една компилация с Delphi с активирано {$R+} като задължителна първоначална проверка за всяко ново демо или тест: настройките по подразбиране на dcc32 няма да ви кажат за този клас грешки и програма, която работи, не е доказателство, че е коректна

Присвояването на TBytes, което само Delphi 13 приема

С едно изречение: присвояването на поле, декларирано като анонимен array of Byte, към променлива TBytes се компилира в Delphi 13 (версия на компилатора 37.0), но се проваля в Delphi 12 Athens и всяка по-ранна версия с грешка E2010 Incompatible types: 'TArray<Byte>' and 'Dynamic array'; Това не е толкова разделение между Delphi и FPC, колкото разделение между Delphi и неговото собствено минало, но засяга същата кодова база с множество компилатори по същия начин: най-новият компилатор тихомълком приема конструкция, която всички останали отхвърлят

type
  TValidator = class
  private
    FBuffer: array of Byte;   // анонимен тип динамичен масив
  end;

var
  OrigBytes: TBytes;
begin
  OrigBytes := FBuffer;          // само Delphi 13; E2010 в Delphi 12
                                 // Athens и по-рано
  OrigBytes := TBytes(FBuffer);  // компилира се навсякъде; същата подредба на байтовете,
                                 // безопасно твърдо преобразуване
end;

Ние доставихме точно това в рутина за валидиране, разработена и тествана локално на Delphi 13, където неявното преобразуване беше прието безшумно; Инсталаторът с пълни изходни кодове обслужва голям брой потребители на Delphi 12 и по-стари версии, и за тях модулът просто не се компилираше; Структурното решение е или твърдото преобразуване, показано по-горе, което е безопасно, тъй като анонимен array of Byte и TBytes споделят идентична структура на динамичния масив, или по-добре — деклариране на полето като именован тип TBytes от самото начало, за да не се налага преобразуване; Процесната корекция е по-важна: конструкция, която се компилира на най-новия ви компилатор, не доказва нищо за по-старите версии, които вашите потребители действително изпълняват, и тази категория регресии е невидима, докато не компилирате срещу всяка поддържана версия; Нашите скриптове за пускане на версии сега компилират библиотеката на пълната матрица от компилатори, именно защото локалната компилация с 37.0 не може да улови ленивост, специфична за 13

Байтът в AnsiString, когото изчезва на китайска Windows машина

С едно изречение: конкатенирането на суров байт със стойност $80 или по-висока в AnsiString с оператора + може тихомълком да замени този байт с ? ($3F) под Delphi, тъй като изразът извършва неявно двупосочно преобразуване AnsiString -> UnicodeString -> AnsiString през системната кодова страница; Открихме това чрез тест за PDF/A, който конструира име, съдържащо изолиран байт $FE, който никога не е валиден водещ байт за UTF-8, за да провери дали валидаторът маркира имена, които не са валиден UTF-8 съгласно ISO 19005-2 клауза 6.1.8

Диаграма на двупосочния цикъл AnsiString към UnicodeString, заменящ суров байт $FE с въпросителен знак на китайска Windows машина с CP936, до безопасната кръпка на байта на място в Delphi
Имплицитното UnicodeString обръщане заменя непреводим $FE байт с $3F под CP936, затова безопасният път закърпва байта на място
var
  BadName: AnsiString;
begin
  // В Delphi с многобайтова системна кодова страница (наблюдавана при CP936),
  // конкатенацията преминава през UnicodeString и $FE, което
  // не е валидна CP936 последователност, се връща като '?' ($3F)
  BadName := '/Bad' + AnsiChar($FE) + 'Name';

  // Безопасно: изградете с ASCII маркер, след което коригирайте байта на място;
  // индексното присвояване в установен AnsiString не извършва преобразуване
  BadName := '/Bad' + #1 + 'Name';
  BadName[5] := AnsiChar($FE);
end;

На китайска Windows система, работеща с кодова страница 936, конкатенираният низ никога не съдържаше $FE, така че библиотеката правилно докладваше нищо и тестът се проваляше, изглеждайки като бъг в библиотеката; Библиотеката никога не е грешала: тестова среда с FPC, която подаваше PDF, действително съдържащ байта $FE, получаваше очаквания флаг; Повредата се случваше вътре в изпълнимия файл на Delphi теста, докато се изчисляваше изразът за низ, тъй като Unicode моделът на Delphi преобразува смесени изрази с AnsiString през UnicodeString, а $FE не е валиден водещ байт в CP936, така че двупосочното преобразуване го заменя; Бъдете честни за границите: на еднобайтова западна кодова страница като CP1252 същият израз обикновено оцелява, което е причината този бъг да се крие на повечето машини за разработка и да се появява само на източноазиатски системи или локализирани CI сървъри; Правилото, което приехме: никога не изграждайте двоични тестови вектори, съдържащи байтове със стойност $80 или по-висока чрез конкатенация на AnsiString; или коригирайте байтовете на място, след като низът е установен, както е показано по-горе, или конструирайте вектора в TBytes от самото начало

Какво трябва да проверява по подразбиране работният процес с два компилатора

Четири капана, един модел: всеки компилатор ви казва за различен поднабор от вашите бъгове; Анализът на FPC по време на компилация улови индекс извън диапазона, който dcc32 изпълняваше безшумно в продължение на месеци, а Unicode моделът на dcc32 разкри зависимост от кодовата страница, която чиста байтово-ориентирана компилация с FPC никога не задейства; Практическото следствие е, че нито един от двата успешни конвейера за компилация не е достатъчен сам по себе си; Крос-компилацията не е просто отметка за преносимост, тя е втори статичен анализатор и втори модел за изпълнение, приложен към същия източник, в същия дух като защитните проверки на границите в статията за укрепване на ABI и безопасността на паметта

Постоянните правила, които произтекоха от тези инциденти, са достатъчно кратки, за да бъдат запомнени; Записвайте резултатите от функции, които са записи, в локална променлива, преди да четете полета; Обхождайте масиви с фиксирани граници чрез Low() и High() и изпълнявайте поне одна компилация с проверка на диапазоните или с FPC, преди да се доверите на ново демо; Преобразувайте анонимните динамични масиви изрично или ги декларирайте с именовани типове, и компилирайте на пълната матрица от компилатори преди пускане на версия; Дръжте суровите високи байтове извън конкатенацията на AnsiString изцяло; Нито едно от тези не струва измеримо усилие, след като се превърне в навик, а всяко от тях затваря режим на отказ, който работният процес с един компилатор структурно не може да види

Всичките четири проблема бяха открити и коригирани в хода на поддръжката на PDFium Component, който доставя същия изходен код на Object Pascal за Delphi, C++Builder и FPC/Lazarus и изпълнява своите пакети за съвместимост и регресия на всяка от тези платформи, така че капаните в тази статия са защитени от тестове, а не от памет