Artículo técnico

Delphi vs FPC: 4 trampas ocultas de código PDF en compilaciones de PDFium

El mismo código fuente de Object Pascal puede comportarse de manera diferente en Delphi y en FPC/Lazarus de cuatro formas que afectan repetidamente al código de PDFium Component: FPC desecha los registros temporales resultantes de funciones antes de que una prueba de pertenencia in termine de leerlos, dcc32 se distribuye con la verificación de rango desactivada por lo que los índices de matriz fuera de los límites leen basura silenciosamente, solo Delphi 13 acepta la asignación de una matriz anónima array of Byte a TBytes sin un cast, y la concatenación de AnsiString en Delphi puede destruir bytes iguales o superiores a $80 a través de un viaje de ida y vuelta oculto por la página de códigos. Cada una de ellas produce una suite de pruebas que está en verde en un compilador y en rojo, o peor aún, que es silenciosamente incorrecta en el otro

Si está configurando un proyecto de compilador dual por primera vez, el tutorial del visor Lazarus y FPC cubre el camino feliz: paquetes, rutas de búsqueda y cómo colocar una ventana de renderizado en pantalla. Este artículo es lo opuesto a un tutorial. Es la lista de cosas con las que nos topamos después de que el camino feliz funcionara, cuando la integración continua (CI) estaba en verde bajo FPC, en verde bajo Delphi, y luego un cambio que se aprobó en un lado detonó en el otro. Cada trampa a continuación proviene de un fallo real en la suite de pruebas de PDFiumPas o en sus demostraciones, con el análisis forense a nivel de commit condensado en una reproducción mínima, la causa raíz y la solución que estandarizamos

¿Por qué un conjunto se lee vacío bajo FPC pero no en Delphi?

La versión de una sola frase: FPC puede finalizar la variable temporal que contiene el resultado de registro de una función antes de que haya terminado una expresión que lee un campo de ese resultado, por lo que X in Func().Issues puede probar la pertenencia a un conjunto ya liberado mientras que la expresión equivalente en Delphi funciona. Nuestras pruebas de conformidad de PDF/E se toparon con esto en su primera versión. El validador devuelve un registro cuyo campo Issues es un conjunto de banderas de infracción, y las aserciones incluyeron la llamada en línea

// 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 en línea leyó el conjunto como vacío bajo FPC, por lo que falló cada aserción que esperaba una bandera, mientras que la compilación idéntica de Delphi se aprobó. La causa raíz es una diferencia en cómo los dos compiladores administran la vida útil de los temporales resultantes de funciones dentro de expresiones más grandes: Delphi mantiene vivo el temporal hasta el final de la instrucción, mientras que la eliminación del registro temporal por parte de FPC puede entrar en conflicto con el operador de pertenencia al conjunto que todavía lo está leyendo. Ya habíamos documentado el mismo comportamiento una vez antes, en un comentario en el ayudante FlagPresent en la unidad de prueba de PDF/A, y luego volvimos a introducir el error de todos modos al escribir nuevas pruebas desde cero, lo que le indica cuán natural parece la forma rota. La solución es mecánica y vale la pena adoptarla como regla general: nunca encadene un acceso a un campo o una prueba de pertenencia directamente a una llamada de función que devuelva un registro; asigne el resultado a una variable local primero, luego lea el campo. Cuesta una sola línea y elimina toda una clase de inestabilidad dependiente del compilador

¿Por qué Delphi acepta un índice de matriz que FPC se niega a compilar?

La versión de una sola frase: dcc32 compila un índice fuera de rango en una matriz de límites fijos y, con su verificación de rango predeterminada desactivada, lee o escribe memoria adyacente en tiempo de ejecución sin ningún error, mientras que FPC rechaza el mismo índice en tiempo de compilación. El PDFium Component declara los puntos del cuadrilátero como una matriz basada en 1, TQuadrilateralPoint = array [1..4] of TPdfPoint, coincidiendo con cómo se suelen numerar las entradas QuadPoints de PDF. Una demostración que la completaba con el bucle reflexivo basado en 0 funcionó durante meses bajo 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 compilación de Delphi fue un falso positivo: con la verificación de rango desactivada (que es el valor predeterminado de dcc32), el índice 0 aterrizó en cualquier campo que preceda a la matriz en el registro, y la demostración pareció ejecutarse. Migrar la misma demostración a Lazarus produjo un error de verificación de rango inmediato en tiempo de compilación por parte de FPC, y corregir el índice expuso un segundo error más profundo en la ruta de anotación de la biblioteca que las lecturas de basura habían estado enmascarando, el analizado en el artículo sobre anotaciones con QuadPoints. Dos lecciones surgieron de ese incidente. Primero, prefiera Low() y High() sobre límites literales siempre que el tipo de matriz no esté basado en 0 por construcción. Segundo, trate una compilación de FPC, o como mínimo una compilación de Delphi con {$R+} habilitada, como una puerta obligatoria de primera ejecución para cualquier demostración o prueba nueva: los valores predeterminados de dcc32 no le informarán sobre esta clase de error, y un programa que se ejecute no es evidencia de que sea correcto

La asignación de TBytes que solo acepta Delphi 13

La versión de una sola frase: asignar un campo declarado como una matriz anónima array of Byte a una variable TBytes se compila en Delphi 13 (versión del compilador 37.0) pero falla en Delphi 12 Athens y en todas las versiones anteriores con E2010 Incompatible types: 'TArray<Byte>' and 'Dynamic array'. Esta división no es tanto entre Delphi y FPC, sino más bien entre Delphi y su propio pasado, pero afecta al mismo código base multi-compilador de la misma manera: el compilador más nuevo acepta silenciosamente una construcción que todos los demás rechazan

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;

Distribuimos exactamente esto en una rutina de validación, desarrollada y probada localmente en Delphi 13, donde la conversión implícita fue aceptada silenciosamente. El instalador de código fuente completo sirve a un gran número de usuarios en Delphi 12 y versiones anteriores, y para ellos la unidad simplemente no se compilaba. La solución estructural es el cast directo (hard cast) mostrado arriba (que es seguro porque un array of Byte anónimo y TBytes comparten un diseño de matriz dinámica idéntico) o, mejor aún, declarar el campo como un tipo con nombre como TBytes en primer lugar para que nunca surja la conversión. La solución del proceso importa más: una construcción que se compila en su cadena de herramientas más nueva no demuestra nada sobre los compiladores más antiguos que realmente ejecutan sus usuarios, y esta categoría de regresión es invisible hasta que compile con respecto a cada versión compatible. Nuestros scripts de lanzamiento ahora compilan la biblioteca en toda la matriz de compiladores precisamente porque una compilación local 37.0 no puede detectar una tolerancia exclusiva de la versión 13

El byte de AnsiString que desaparece en una máquina Windows en chino

La versión de una sola frase: concatenar un byte sin procesar igual o superior a $80 en una AnsiString con + puede reemplazar silenciosamente ese byte con ? ($3F) bajo Delphi, porque la expresión realiza un viaje de ida y vuelta implícito de AnsiString a UnicodeString y de nuevo a AnsiString a través de la página de códigos del sistema. Encontramos esto a través de una prueba de PDF/A que construye un nombre que contiene un byte $FE aislado (que nunca es un byte inicial UTF-8 legal) para verificar que el validador marque los nombres que no son UTF-8 válidos según la cláusula 6.1.8 de ISO 19005-2

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;

En un sistema Windows en chino que ejecutaba la página de códigos 936, la cadena concatenada nunca contuvo $FE en absoluto, por lo que la biblioteca correctamente no informó de nada y la prueba se puso en rojo pareciendo un error de la biblioteca. La biblioteca nunca estuvo equivocada: un arnés de FPC que alimentó un PDF que contenía genuinamente el byte $FE obtuvo la bandera esperada. La corrupción ocurrió dentro del ejecutable de prueba de Delphi mientras se evaluaba la expresión de la cadena, porque el modelo de cadena de Delphi que prioriza Unicode convierte expresiones AnsiString mezcladas a través de UnicodeString, y $FE no es un byte inicial válido en CP936 por lo que el viaje de ida y vuelta lo reemplaza. Seamos honestos sobre el límite aquí: en una página de códigos occidental de un solo byte como CP1252, la misma expresión generalmente sobrevive, que es exactamente la razón por la cual este error se oculta en la mayoría de las máquinas de desarrollo y surge solo en sistemas de Asia Oriental o en ejecutores de CI localizados. La regla que adoptamos: nunca construya vectores de prueba binarios que contengan bytes iguales o superiores a $80 mediante la concatenación de AnsiString; parchee los bytes en su lugar después de que la cadena se haya establecido, como se describió anteriormente, o construya el vector en TBytes desde el principio

Qué debería verificar por defecto un flujo de trabajo de compilador dual

Cuatro trampas, un patrón: cada compilador le informa sobre un subconjunto de sus errores. El análisis de rango en tiempo de compilación de FPC detectó un índice fuera de los límites que dcc32 ejecutó silenciosamente durante meses, y el modelo de cadena Unicode de dcc32 expuso una dependencia de la página de códigos que una compilación pura de FPC orientada a bytes nunca activa. La consecuencia práctica es que ninguna de las tuberías en verde es suficiente por sí sola. La compilación cruzada no es solo una casilla de verificación de portabilidad, es un segundo analizador estático y un segundo modelo de tiempo de ejecución aplicados a la misma fuente, con el mismo espíritu que las comprobaciones defensivas de límites en el artículo sobre el endurecimiento de la seguridad de la memoria y la ABI

Las reglas permanentes que surgieron de estos incidentes son lo suficientemente cortas como para memorizarlas. Fije los registros de resultados de funciones a una variable local antes de leer los campos. Intere matrices de límites fijos con Low() y High(), y ejecute al menos una compilación de FPC o con verificación de rango activada antes de confiar en cualquier demostración nueva. Realice el cast de campos de matriz dinámica anónimos de forma explícita, o declárelos con tipos con nombre, y compile la matriz completa de compiladores antes del lanzamiento. Mantenga los bytes altos sin procesar fuera de la concatenación de AnsiString por completo. Ninguno de estos cuesta un esfuerzo medible una vez que son hábitos, y cada uno cierra un modo de fallo que un flujo de trabajo de un solo compilador estructuralmente no puede ver

Los cuatro problemas se encontraron y corrigieron en el transcurso del mantenimiento de PDFium Component, que distribuye el mismo código fuente de Object Pascal para Delphi, C++Builder y FPC/Lazarus y ejecuta sus suites de conformidad y regresión en cada una de esas cadenas de herramientas, por lo que las trampas de este artículo están protegidas por pruebas en lugar de por memoria