Articolo tecnico

Delphi vs FPC: 4 trappole nascoste nel codice PDF nelle build PDFium

Lo stesso codice sorgente Object Pascal può comportarsi in modo diverso tra Delphi e FPC/Lazarus in quattro ambiti che interessano ripetutamente il codice del componente PDFium: FPC elimina le variabili temporanee dei record restituiti dalle funzioni prima che un test di appartenenza in termini di leggerle, dcc32 viene distribuito con il controllo dell'intervallo disattivato per cui gli indici di array fuori limite leggono silenziosamente dati non validi, solo Delphi 13 accetta l'assegnazione di un array anonimo array of Byte a TBytes senza un cast, e la concatenazione AnsiString di Delphi può corrompere i byte uguali o superiori a $80 tramite una conversione di tabella codici nascosta. Ciascuna di queste problematiche produce test di successo su un compilatore e di fallimento, o peggio, silenziosamente errati, sull'altro

Se stai configurando un progetto a doppio compilatore per la prima volta, la guida al visualizzatore Lazarus e FPC illustra il percorso lineare: pacchetti, percorsi di ricerca e visualizzazione di una finestra di rendering a schermo. Questo articolo è l'opposto di un tutorial. Rappresenta la lista di problemi riscontrati dopo il funzionamento del percorso di base, quando l'integrazione continua (CI) risultava corretta sia sotto FPC sia sotto Delphi, ma una modifica accettata da una parte generava errori sull'altra. Ciascuna delle trappole descritte di seguito deriva da un fallimento reale nella suite di test PDFiumPas o nelle sue demo, con l'analisi condensata in una riproduzione minima, la causa principale e la correzione standard adottata

Perché un set viene letto come vuoto sotto FPC ma non in Delphi?

In breve: FPC può finalizzare la variabile temporanea contenente il record restituito da una funzione prima del termine di un'espressione che legge un campo di quel risultato, per cui X in Func().Issues può verificare l'appartenenza rispetto a un set già rilasciato, mentre l'espressione equivalente in Delphi funziona correttamente. I nostri test di conformità PDF/E hanno riscontrato questa problematica nella loro prima versione. Il validatore restituisce un record il cui campo Issues è un set di flag di violazione, e le asserzioni contenevano la chiamata in linea

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

La forma in linea leggeva il set como vuoto sotto FPC, per cui ogni asserzione che si aspettava un flag falliva, mentre la build identica in Delphi andava a buon fine. La causa risiede in una differenza nel modo in cui i due compilatori gestiscono la durata delle variabili temporanee restituite dalle funzioni all'interno di espressioni più ampie: Delphi mantiene in vita la variabile temporanea fino alla fine dell'istruzione, mentre la rimozione da parte di FPC può anticipare l'operatore di appartenenza in che la sta ancora leggendo. Avevamo già documentato lo stesso comportamento in precedenza, in un commento sull'helper FlagPresent nell'unità di test PDF/A, per poi reintrodurre comunque il bug scrivendo nuovi test da zero, il che dimostra quanto la forma errata appaia naturale. La correzione è sistematica e merita di essere adottata come regola generale: non concatenare mai l'accesso a un campo o il test di un set direttamente a una chiamata di funzione che restituisce un record; assegna prima il risultato a una variabile locale, quindi leggi il campo. Costa una sola riga di codice ed elimina un'intera classe di comportamenti instabili legati al compilatore

Perché Delphi accetta un indice di array che FPC rifiuta di compilare?

In breve: dcc32 compila un indice fuori intervallo in un array a limiti fissi e, con il controllo dell'intervallo disattivato per impostazione predefinita, legge o scrive la memoria adiacente in fase di esecuzione senza generare errori, mentre FPC rifiuta lo stesso indice in fase di compilazione. Il componente PDFium dichiara i punti quadrilaterali come un array a base 1, TQuadrilateralPoint = array [1..4] of TPdfPoint, allineandosi alle voci QuadPoints dei PDF. Una demo che compilava l'array con un ciclo a base 0 ha funzionato per mesi sotto 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;

La build in Delphi era un falso positivo: con il controllo dell'intervallo disattivato (impostazione predefinita di dcc32), l'indice 0 andava a scrivere sul campo che precedeva l'array nel record, e la demo sembrava funzionare. Il porting della stessa demo su Lazarus ha generato un errore immediato di controllo dell'intervallo da parte di FPC in fase di compilazione, e la correzione dell'indice ha poi rivelato un secondo bug, più profondo, nel percorso delle annotazioni della libreria che le letture errate stavano mascherando, quello descritto nell'articolo sulle annotazioni quad-points. Da quell'evento sono emerse due lezioni. Primo, preferisci Low() e High() rispetto ai limiti letterali ogni volta che l'array non è configurato a base 0 per costruzione. Secondo, considera la compilazione in FPC, o almeno una build in Delphi con {$R+} attivato, come un passaggio obbligatorio per ogni nuova demo o test: le impostazioni predefinite di dcc32 non evidenziano questo tipo di bug, e un programma che viene eseguito senza errori non è garanzia di correttezza

L'assegnazione TBytes accettata solo da Delphi 13

In breve: l'assegnazione di un campo dichiarato come array of Byte anonimo a una variabile TBytes viene compilata in Delphi 13 (versione del compilatore 37.0) ma fallisce in Delphi 12 Athens e in tutte le versioni precedenti con l'errore E2010 Incompatible types: 'TArray<Byte>' and 'Dynamic array'. Non si tratta di una differenza tra Delphi e FPC, ma tra Delphi e le sue versioni precedenti, che incide comunque sul codice multicompilatore: il compilatore più recente accetta una sintassi che tutte le altre versioni rifiutano

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;

Abbiamo introdotto proprio questo comportamento in una routine di convalida, sviluppata e testata su Delphi 13, in cui la conversione implicita veniva accettata silenziosamente. L'installatore con sorgente completo serve molti utenti su Delphi 12 e versioni precedenti, per i quali l'unità semplicemente non veniva compilata. La correzione strutturale prevede il cast esplicito mostrato sopra (sicuro poiché un array of Byte anonimo e TBytes condividono lo stesso layout di array dinamico) o, preferibilmente, la dichiarazione originaria del campo come tipo denominato come TBytes per evitare conversioni. La soluzione a livello di processo è importante: un codice che viene compilato sulla toolchain più recente non dimostra nulla sui compilatori meno recenti usati dagli utenti, e questo tipo di regressione rimane invisibile finché non si compila rispetto a ogni versione supportata. I nostri script di rilascio compilano ora la libreria sull'intera matrice dei compilatori proprio perché una build 37.0 locale non può rilevare una tolleranza introdotta solo nella versione 13

Il byte AnsiString che scompare su un sistema Windows in lingua cinese

In breve: concatenare un byte grezzo uguale o superiore a $80 in una AnsiString tramite l'operatore + può sostituire silenziosamente quel byte con ? ($3F) sotto Delphi, in quanto l'espressione esegue una conversione implicita AnsiString -> UnicodeString -> AnsiString tramite la tabella codici di sistema. Abbiamo riscontrato questo problema tramite un test PDF/A che crea un nome contenente un byte $FE isolato (mai valido come lead byte UTF-8) per verificare che il validatore segnali i nomi non conformi allo standard ISO 19005-2 clausola 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;

Su un sistema Windows in lingua cinese con tabella codici 936, la stringa concatenata non conteneva affatto il byte $FE, per cui la libreria non segnalava alcuna anomalia e il test falliva indicando un presunto bug della libreria. La libreria si comportava correttamente: un test FPC che inseriva un PDF contenente realmente il byte $FE riceveva il flag previsto. La corruzione avveniva all'interno dell'eseguibile di test in Delphi durante la valutazione dell'espressione stringa, poiché il modello di stringhe incentrato su Unicode di Delphi converte le espressioni AnsiString miste tramite UnicodeString, e $FE non rappresenta un lead byte valido in CP936 per cui la conversione lo sostituisce. Occorre considerare i limiti: su una tabella codici occidentale a byte singolo come CP1252 la stessa espressione di lavoro di solito funziona, motivo per cui questo bug rimane nascosto sulla maggior parte dei computer di sviluppo ed emerge solo su sistemi dell'Asia orientale o su runner CI localizzati. La regola adottata: non compilare mai vettori di test binari contenenti byte uguali o superiori a $80 tramite concatenazione AnsiString; modifica i byte sul posto dopo che la stringa è stata definita, come mostrato sopra, o crea il vettore in formato TBytes fin dall'inizio

Cosa dovrebbe verificare per impostazione predefinita un flusso di lavoro a doppio compilatore

Quattro trappole, un solo pattern: ciascun compilatore evidenzia un diverso sottoinsieme di bug. L'analisi degli intervalli in fase di compilazione di FPC ha intercettato un indice fuori limite eseguito silenziosamente da dcc32 per mesi, e il modello di stringhe Unicode di dcc32 ha esposto una dipendenza dalla tabella codici che una build FPC focalizzata sui byte non avrebbe mai attivato. La conseguenza è che una sola pipeline con esito positivo non è sufficiente. La compilazione incrociata non è solo una verifica di portabilità, ma rappresenta un secondo analizzatore statico e un secondo modello di esecuzione applicati allo stesso codice sorgente, nello stesso spirito dei controlli difensivi descritti nell'articolo sul consolidamento della sicurezza della memoria e delle ABI

Le regole derivate da questi incidenti sono semplici da ricordare. Assegna i record restituiti dalle funzioni a una variabile locale prima di leggerne i campi. Itera gli array a limiti fissi tramite Low() e High(), ed esegui almeno una build con controllo dell'intervallo o in FPC prima di considerare sicura una demo. Esegui il cast esplicito dei campi array dinamici anonimi, o dichiarali con tipi denominati, ed esegui la compilazione sulla matrice completa dei compilatori prima del rilascio. Evita del tutto i byte grezzi alti nella concatenazione AnsiString. Nessuna di queste pratiche richiede uno sforzo significativo una volta acquisita come abitudine, ed ognuna previene errori che un flusso basato su un solo compilatore non potrebbe rilevare

Tutti e quattro i problemi sono stati individuati e risolti durante lo sviluppo del componente PDFium, che distribuisce lo stesso codice sorgente Object Pascal per Delphi, C++Builder e FPC/Lazarus ed esegue i test di conformità e regressione su ciascuna di queste toolchain, garantendo che le problematiche descritte in questo articolo siano presidiate da test dedicati