Teknisk artikkel

Delphi vs FPC: 4 skjulte PDF-kodefeller i PDFium-bygg

Den samme Object Pascal-kildekoden kan oppføre seg ulikt under Delphi og FPC/Lazarus på fire måter som gjentatte ganger rammer koden til PDFium Component: FPC frigjør funksjonsresultat-post-temporære før en in-medlemskapstest er ferdig med å lese dem, dcc32 leveres med grensekontroll slått av slik at tabellindekser utenfor grensene leser søppel i stillhet, bare Delphi 13 godtar tildeling av en anonym array of Byte to TBytes uten en typekonvertering (cast), og Delphis AnsiString-konkatenering kan ødelegge byte på eller over $80 gjennom en skjult tur-retur via systemets tegnsett (code-page). Hver av disse produserer et testsett som er grønt på én kompilator, men rødt, eller enda verre, lydløst feil på den andre

Hvis du setter opp et prosjekt for doble kompilatorer for første gang, gjennomgangen av Lazarus- og FPC-visningen dekker den enkle banen: pakker, søkestier og å få et renderingsvindu på skjermen. Denne artikkelen er det motsatte av en veiledning. Det er listen over ting vi traff etter at den enkle banen fungerte, da CI was green under FPC, green under Delphi, and then a change that passed on one side detonated on the other. Hver felle nedenfor kommer fra en reell feil i testsettet til PDFiumPas eller dets demoer, der rettsmedisinen på commit-nivå er kondensert til en minimal reproduksjon, rotårsaken og løsningen vi standardiserte på

Hvorfor leses et sett som tomt under FPC, men ikke i Delphi?

Kort fortalt: FPC kan sluttføre (finalize) den midlertidige variabelen som holder en funksjons post-resultat før et uttrykk som leser et felt i det resultatet er ferdig, slik at X in Func().Issues kan teste medlemskap mot et allerede frigitt sett, mens det tilsvarende Delphi-uttrykket fungerer. Våre PDF/E-samsvarstester traff dette i sin første versjon. Validatoren returnerer en post der feltet Issues er et sett med brudd-flagg, and the assertions inlined the call

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

Den inlinede formen leste settet som tomt under FPC, så hver assert-setning som forventet et flagg feilet, mens det identiske Delphi-bygget passerte. Rotårsaken er en forskjell i hvordan de to kompilatorene administrerer levetiden til midlertidige funksjonsresultater i større uttrykk: Delphi holder den midlertidige variabelen i live til slutten av setningen, mens FPCs sletting av den midlertidige posten kan kappkjøre med medlemskapsoperatoren (set-membership) som fremdeles leser den. Vi hadde allerede dokumentert den samme oppførselen én gang før, i en kommentar på hjelpefunksjonen FlagPresent i PDF/A-testenheten, og gjeninnførte deretter feilen uansett da vi skrev nye tester fra bunnen av, noe som forteller deg hvor naturlig den ødelagte formen ser ut. Løsningen er mekanisk og verdt å innføre som en generell regel: koble aldri en felttilgang eller en medlemskapstest direkte på et funksjonskall som returnerer en post; tildel resultatet til en lokal variabel først, og les deretter feltet. Det koster én linje og fjerner en hel klasse med kompilator-avhengig ustabilitet

Hvorfor godtar Delphi en tabellindeks som FPC nekter å kompilere?

Kort fortalt: dcc32 kompilerer en indeks utenfor grensene til en tabell med faste grenser, og med sin standard grensekontroll deaktivert, leser eller skriver den tilstøtende minne ved kjøring uten noen feil, mens FPC avviser den samme indeksen ved kompilering. PDFium Component deklarerer firkantpunkter som en 1-basert tabell, TQuadrilateralPoint = array [1..4] of TPdfPoint, noe som samsvarer med hvordan PDF-ers QuadPoints-oppføringer vanligvis er nummerert. En demo som fylte den med den refleksive 0-baserte løkken fungerte i månedsvis under 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;

Delphi-bygget var et falskt positivt resultat: med grensekontroll av (som er standard for dcc32), lander indeks 0 på det feltet som ligger rett foran tabellen i posten, og demoen så ut til å kjøre. Å portere den samme demoen til Lazarus ga en umiddelbar feil under kompilering om grensekontroll fra FPC, og å fikse indeksen avdekket deretter en annen, dypere feil i bibliotekets annotasjonsbane som de feilaktige lesingene hadde skjult, den som er analysert i artikkelen om firkantpunkter (quad-points) for annotasjon. To lærdommer kom ut av den hendelsen: For det første, foretrekk Low() og High() fremfor bokstavelige grenser når tabelltypen ikke er 0-basert av konstruksjon, og for det andre, behandle en FPC-kompilering (eller i det minste ett Delphi-bygg med {$R+} aktivert) som en obligatorisk port for første kjøring av enhver ny demo eller test. Standardinnstillingene til dcc32 vil ikke fortelle deg om denne typen feil, og et program som kjører er ikke bevis på at det er riktig

TBytes-tildelingen som bare Delphi 13 godtar

Kort fortalt: tildeling av et felt deklarert som en anonym array of Byte til en TBytes-variabel kompilerer på Delphi 13 (kompilatorversjon 37.0), men feiler på Delphi 12 Athens og alle tidligere utgivelser med E2010 Incompatible types: 'TArray<Byte>' and 'Dynamic array'. Dette er ikke så mye et skille mellom Delphi og FPC som et skille mellom Delphi og dens egen fortid, men det rammer den samme kildekoden for flere kompilatorer på samme måte: den nyeste kompilatoren godtar i stillhet en konstruksjon som alt annet avviser

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;

Vi leverte akkurat dette i en valideringsrutine, utviklet og testet lokalt på Delphi 13, der den implisitte konverteringen ble godtatt i stillhet. Fullkilde-installasjonsprogrammet betjener brukere på Delphi 12 og eldre i store antall, og for dem kompilerte enheten rett og slett ikke. Den strukturelle løsningen er enten den harde typekonverteringen vist ovenfor, som er trygg fordi en anonym array of Byte og TBytes deler en identisk dynamisk tabellstruktur, eller enda bedre, å deklarere feltet som en navngitt type som TBytes i utgangspunktet slik at ingen konvertering noen gang oppstår. Løsningen på prosessnivå er viktigere: en konstruksjon som kompilerer på din nyeste verktøykjede beviser ingenting om de eldre kompilatorene brukerne dine faktisk kjører, og denne kategorien av regresjoner er usynlig før du bygger mot alle støttede versjoner. Utgivelsesskriptene våre kompilerer nå biblioteket på tvers av hele kompilatormatrisen nettopp fordi et lokalt 37.0-bygg ikke kan fange opp en toleranse som bare gjelder for versjon 13

AnsiString-byten som forsvinner på en kinesisk Windows-maskin

Kort fortalt: konkatenere en rå byte på eller over $80 inn i en AnsiString med + kan i stillhet erstatte den byten med ? ($3F) under Delphi, fordi uttrykket tar en underliggende AnsiString til UnicodeString til AnsiString-tur-retur via systemets tegnsett (code page). Vi fant dette gjennom en PDF/A-test som konstruerer et navn som inneholder en isolert $FE-byte (noe som aldri er en gyldig UTF-8-ledende byte), for å verifisere at validatoren flagger navn som ikke er gyldig UTF-8 i henhold til ISO 19005-2 klausul 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;

På et kinesisk Windows-system som kjører tegnsett 936, inneholdt den konkatenerte strengen aldri $FE i det hele tatt, så biblioteket rapporterte helt riktig ingenting, og testen ble rød mens det så ut som en feil i biblioteket. Biblioteket tok aldri feil: et FPC-testsett som matet inn en PDF som genuint inneholdt $FE-byten fikk det forventede flagget. Korrupsjonen skjedde inne i Delphi-testprogrammet mens strenguttrykket ble evaluert, fordi Delphis Unicode-første strengmodell konverterer blandede AnsiString-uttrykk via UnicodeString, og $FE er ikke en gyldig start-byte i CP936, så tur-retur-reisen erstatter den. Vær ærlig om grensen her: på et ensbites vestlig tegnsett som CP1252 overlever vanligvis det samme uttrykket, som er akkurat grunnen til at denne feilen skjuler seg på de fleste utviklingsmaskiner og bare dukker opp på østasiatiske systemer eller lokaliserte CI-kjøringer. Regelen vi innførte: bygg aldri binære testvektorer som inneholder byte på eller over $80 ved hjelp av AnsiString-konkatenering; enten patch byte på plass etter at strengen er etablert (som ovenfor), eller konstruer vektoren i TBytes fra starten av

Hva en arbeidsflyt med doble kompilatorer bør sjekke som standard

Fire feller, ett mønster: hver kompilator forteller deg om en ulik delmengde av feilene dine. FPC-ens grenseanalyse ved kompilering fanget opp en indeks utenfor grensene som dcc32 kjørte i stillhet i månedsvis, og dcc32-ens Unicode-strengmodell avslørte en avhengighet av tegnsettet som et rent byte-orientert FPC-bygg aldri utløser. Den praktiske konsekvensen er at ingen av de grønne pipelinene er tilstrekkelige alene. Krysskompilering er ikke just a portability checkbox, it is a second static analyzer and a second runtime model applied to the same source, in the same spirit as the defensive boundary checks in the ABI and memory-safety hardening article

De stående reglene som fulgte av disse hendelsene er korte nok til å huskes utenat. Bind funksjonsresultat-poster til en lokal variabel før du leser felt. Gå gjennom tabeller med faste grenser ved hjelp av Low() og High(), og kjør minst ett grensekontrollert eller FPC-basert bygg før du stoler på en ny demo. Konverter anonyme dynamiske tabellfelt eksplisitt, eller deklarer dem med navngitte typer, og bygg hele kompilatormatrisen før utgivelse. Hold rå høye byte helt unna AnsiString-konkatenering. Ingen av disse krever nevneverdig innsats når de først har blitt vaner, og hver enkelt fjerner en feilmodus som en arbeidsflyt med bare én kompilator strukturelt ikke kan se

Alle de fire problemene ble funnet og fikset i løpet av vedlikeholdet av PDFium Component, som leverer den samme Object Pascal-kildekoden for Delphi, C++Builder og FPC/Lazarus, og kjører sine samsvars- og regresjonstester på hver av disse verktøykjedene, slik at fellene i denne artikkelen voktes av tester snarere enn av hukommelsen